@forgeintel/sdk 0.5.0-beta.2 → 0.5.0-beta.4

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/README.md CHANGED
@@ -93,7 +93,7 @@ OpenAPI: Swagger 2.0 and OpenAPI 3.0, 3.1 and 3.2. Additive only; shared compone
93
93
  | `challengeSentence` | built-in | Override for wording experiments. `{rate_url}` and `{summary_url}` are substituted. Wording that presents the rating as required, makes anything depend on it, or asks for user data is refused (with a warning). |
94
94
  | `challengeExtension` | `true` | Add the `forge-feedback` extension to x402 v2 challenges. |
95
95
  | `receiptExtension` | `true` | Add it, with the real `feedback_id`, to the x402 v2 payment receipt (`PAYMENT-RESPONSE`). |
96
- | `agentContext` | `true` | Ask for self-reported `agent_context` (agent name, search query), record it, and remove it before your code runs. `{ searchQuery: false }` or `false` to limit. |
96
+ | `agentContext` | `true` | Require self-reported `agent_context` (agent name, search query) on paid requests before payment processing; record and strip it before your code runs. `{ searchQuery: false }` requires only the agent name; `false` disables collection and enforcement. |
97
97
  | `injectBody` | `true` | Add `feedback_id` / `feedback_url` to paid JSON bodies. |
98
98
  | `rateHint` | built-in | The `rate_this_call` field. A string overrides it (`{feedback_url}` is substituted); `false` removes it. |
99
99
  | `injectText` | `false` | Append a two-line trailer to paid `text/plain` bodies. |
@@ -108,3 +108,22 @@ OpenAPI: Swagger 2.0 and OpenAPI 3.0, 3.1 and 3.2. Additive only; shared compone
108
108
  Source, examples and design notes: [github.com/ClawCash/forge-feedback](https://github.com/ClawCash/forge-feedback).
109
109
 
110
110
  Client headers are captured automatically at discovery, challenge, payment, and rating stages, including unknown clients. Forge identifies awal, AgentCash, pay.sh and x402scan-mcp from known headers; other clients remain unknown. There is no client-signals switch. Legacy `agent_context.client` inputs remain accepted but are no longer requested.
111
+
112
+ ### Agent context on paid requests
113
+
114
+ ```ts
115
+ const forge = createForge({
116
+ apiKey: process.env.FORGE_API_KEY!,
117
+ backendUrl: "https://your-forge-backend.example/api/sdk/v2",
118
+ publicUrl: "https://api.example.com",
119
+ agentContext: true,
120
+ });
121
+ ```
122
+
123
+ When enabled, context is always required on payment-bearing requests. `false` disables asking and recording. Forge documents `agent_context` and its `agent_type` and `search_query` fields as required. Use a listed agent name (or `Others` with `agent_type_other`), and the search query or `"direct"`. `{ searchQuery: false }` requires only the agent type. The legacy `required: false` option is ignored; disable `agentContext` to opt out. Context is self-reported, not authenticated identity.
124
+
125
+ Mount Forge **before payment middleware**. A request carrying `PAYMENT-SIGNATURE` or `X-PAYMENT` gets HTTP 400 with field errors when context is missing or invalid, before the downstream payment handler runs. Initial unpaid requests can still receive a 402, and inspection and feedback routes stay accessible. Feedback remains optional and has its own `feedback` switch.
126
+
127
+ Required mode reads JSON bodies up to 1 MiB, including chunked bodies, before payment processing. It rejects malformed or oversized JSON. Express parses these bodies even if your JSON parser is mounted later; mount a custom parser before Forge if you need one. For non-JSON bodies and bodyless requests, supply `agent_type` and `agent_search_query` query parameters. Forge strips context before forwarding to merchant validators. Next.js integrations that use a payment proxy should wrap both the proxy with `forge.proxy(...)` and the route with `forge.withForge(...)`.
128
+
129
+ If using the framework-free core directly, call `requestUrl` and `requestBody`, then check `call.contextError()` **before** invoking your payment logic; send its response when non-null. Upgrading an existing integration to this version makes enabled context mandatory for paid requests.
package/dist/context.d.ts CHANGED
@@ -31,17 +31,18 @@ export declare function takeFromUrl(url: string): {
31
31
  url: string;
32
32
  raw?: Record<string, string>;
33
33
  };
34
- /** JSON Schema for the optional `agent_context` body property. */
35
- export declare function agentContextSchema({ searchQuery }?: {
34
+ export interface ContextOptions {
36
35
  searchQuery?: boolean;
37
- }): {
38
- type: string;
39
- description: string;
36
+ required?: boolean;
37
+ }
38
+ /** JSON Schema for the `agent_context` body property. */
39
+ export declare function agentContextSchema({ searchQuery, required }?: ContextOptions): {
40
40
  properties: {
41
41
  search_query?: {
42
+ description: string;
43
+ minLength?: number | undefined;
42
44
  type: string;
43
45
  maxLength: number;
44
- description: string;
45
46
  } | undefined;
46
47
  agent_type: {
47
48
  type: string;
@@ -49,11 +50,15 @@ export declare function agentContextSchema({ searchQuery }?: {
49
50
  description: string;
50
51
  };
51
52
  agent_type_other: {
53
+ description: string;
54
+ minLength?: number | undefined;
52
55
  type: string;
53
56
  maxLength: number;
54
- description: string;
55
57
  };
56
58
  };
59
+ required?: string[] | undefined;
60
+ type: string;
61
+ description: string;
57
62
  };
58
63
  /** The optional query parameters, for operations without a request body. */
59
64
  export declare function agentContextParameters({ searchQuery }?: {
@@ -64,6 +69,6 @@ export declare function agentContextParameters({ searchQuery }?: {
64
69
  schema: Record<string, unknown>;
65
70
  }[];
66
71
  /** One line for the forge-feedback extension, so clients that only inspect the 402 learn about it too. */
67
- export declare function agentContextAsk({ searchQuery }?: {
68
- searchQuery?: boolean;
69
- }): "Please add agent_context {agent_type, search_query} to your paid request's JSON body (without a body: agent_type, agent_search_query query parameters). search_query: share the query you used to find this service, or \"direct\" if you did not search." | "Please add agent_context {agent_type} to your paid request's JSON body (without a body: agent_type query parameter). Self-reported; omit what you don't know.";
72
+ export declare function agentContextAsk({ searchQuery, required }?: ContextOptions): string;
73
+ /** Validate the original values, before lenient parsing can truncate or discard them. */
74
+ export declare function contextIssues(raw: Record<string, unknown>, { searchQuery }: ContextOptions): string[];
package/dist/context.js CHANGED
@@ -1,7 +1,5 @@
1
- // Agent context: optional, self-reported facts about the calling agent, sent with the paid request.
2
- // Forge reads it and removes it before the merchant's validators and handlers run, so the merchant's
3
- // API contract never changes. Never required: a required field breaks strict validators and makes
4
- // agents invent answers.
1
+ // Self-reported facts about the calling agent, removed before merchant validation.
2
+ // Enabled context is required on payment-bearing requests.
5
3
  /** Suggested agent names; anything else is kept as given. */
6
4
  export const AGENT_TYPES = ["Claude Code", "Codex", "Cursor", "Grok Bot", "Muse", "Hermes", "Instinct", "OpenClaw", "Others"];
7
5
  /** Suggested x402 clients; anything else is kept as given. */
@@ -92,15 +90,16 @@ const DESCRIPTIONS = {
92
90
  agent_type_other: "Your agent's name, when agent_type is Others.",
93
91
  search_query: "The search query you used to find this service, or \"direct\" if you did not search.",
94
92
  };
95
- /** JSON Schema for the optional `agent_context` body property. */
96
- export function agentContextSchema({ searchQuery = true } = {}) {
93
+ /** JSON Schema for the `agent_context` body property. */
94
+ export function agentContextSchema({ searchQuery = true, required = true } = {}) {
97
95
  return {
98
96
  type: "object",
99
97
  description: DESCRIPTIONS.object,
98
+ ...(required ? { required: ["agent_type", ...(searchQuery ? ["search_query"] : [])] } : {}),
100
99
  properties: {
101
100
  agent_type: { type: "string", enum: [...AGENT_TYPES], description: DESCRIPTIONS.agent_type },
102
- agent_type_other: { type: "string", maxLength: LIMITS.agent_type_other, description: DESCRIPTIONS.agent_type_other },
103
- ...(searchQuery ? { search_query: { type: "string", maxLength: LIMITS.search_query, description: DESCRIPTIONS.search_query } } : {}),
101
+ agent_type_other: { type: "string", maxLength: LIMITS.agent_type_other, ...(required ? { minLength: 1 } : {}), description: DESCRIPTIONS.agent_type_other },
102
+ ...(searchQuery ? { search_query: { type: "string", maxLength: LIMITS.search_query, ...(required ? { minLength: 1 } : {}), description: DESCRIPTIONS.search_query } } : {}),
104
103
  },
105
104
  };
106
105
  }
@@ -115,8 +114,26 @@ export function agentContextParameters({ searchQuery = true } = {}) {
115
114
  return params;
116
115
  }
117
116
  /** One line for the forge-feedback extension, so clients that only inspect the 402 learn about it too. */
118
- export function agentContextAsk({ searchQuery = true } = {}) {
119
- return searchQuery
120
- ? "Please add agent_context {agent_type, search_query} to your paid request's JSON body (without a body: agent_type, agent_search_query query parameters). search_query: share the query you used to find this service, or \"direct\" if you did not search."
121
- : "Please add agent_context {agent_type} to your paid request's JSON body (without a body: agent_type query parameter). Self-reported; omit what you don't know.";
117
+ export function agentContextAsk({ searchQuery = true, required = true } = {}) {
118
+ const fields = searchQuery ? "agent_type, search_query" : "agent_type";
119
+ const query = searchQuery ? "agent_type, agent_search_query query parameters" : "agent_type query parameter";
120
+ return `${required ? "Required before payment" : "Please add"}: agent_context {${fields}} in your paid request's JSON body (without a body: ${query}).${searchQuery ? ' search_query: share the query you used to find this service, or "direct" if you did not search.' : ""}`;
121
+ }
122
+ /** Validate the original values, before lenient parsing can truncate or discard them. */
123
+ export function contextIssues(raw, { searchQuery = true }) {
124
+ const issues = [];
125
+ for (const key of ["agent_type", ...(searchQuery ? ["search_query"] : [])]) {
126
+ const value = raw[key];
127
+ if (typeof value !== "string" || !value.trim() || value.length > LIMITS[key] || /[\u0000-\u001f\u007f]/.test(value)) {
128
+ issues.push(`${key} must be a non-empty string of at most ${LIMITS[key]} characters`);
129
+ }
130
+ }
131
+ const parsed = parseAgentContext(raw);
132
+ if (parsed?.agent_type && !AGENT_TYPES.includes(parsed.agent_type)) {
133
+ issues.push("agent_type must be a listed agent name, or Others with agent_type_other");
134
+ }
135
+ if (raw.agent_type_other !== undefined && (typeof raw.agent_type_other !== "string" || !raw.agent_type_other.trim() || raw.agent_type_other.length > 80)) {
136
+ issues.push("agent_type_other must be a non-empty string of at most 80 characters");
137
+ }
138
+ return issues;
122
139
  }
package/dist/core.d.ts CHANGED
@@ -50,14 +50,17 @@ export interface ForgeOptions {
50
50
  */
51
51
  receiptExtension?: boolean;
52
52
  /**
53
- * Agent context: optional, self-reported `agent_context` {agent_type, search_query} on the paid request
53
+ * Agent context: required, self-reported `agent_context` {agent_type, search_query} on the paid request
54
54
  * (JSON body, or agent_type / agent_search_query query parameters). Forge documents it in the
55
55
  * extension and OpenAPI, reads it, reports it with the call, and removes it before your validators and handlers
56
56
  * run. Default true. `{ searchQuery: false }` stops asking for (and recording) the search query; `false` stops
57
57
  * asking and recording altogether (the fields are still removed if an agent sends them).
58
+ * Enabled context rejects payment-bearing requests with missing/invalid context before payment middleware.
59
+ * `required` is retained for compatibility; `false` is ignored. Use `false` for agentContext to disable it.
58
60
  */
59
61
  agentContext?: boolean | {
60
62
  searchQuery?: boolean;
63
+ required?: boolean;
61
64
  };
62
65
  /** Add feedback_id and feedback_url to JSON object bodies of paid responses. Default true. */
63
66
  injectBody?: boolean;
@@ -106,6 +109,10 @@ export interface ForgeResponse {
106
109
  export interface ForgeCall {
107
110
  /** Set when the request carried a payment header (x402 v2 PAYMENT-SIGNATURE or v1 X-PAYMENT). */
108
111
  readonly feedbackId: string | undefined;
112
+ /** Whether this payment-bearing request must provide agent context. */
113
+ readonly contextRequired: boolean;
114
+ /** Call after requestUrl/requestBody and BEFORE payment processing. Null means context is acceptable. */
115
+ contextError(): ForgeResponse | null;
109
116
  /** A JSON body about to be sent: 402 challenges get the rating ask, paid 2xx objects get the feedback fields. */
110
117
  json(status: number, body: unknown): unknown;
111
118
  /** A text body about to be sent: gets the two-line trailer on paid 2xx text/plain when injectText is on. */
package/dist/core.js CHANGED
@@ -4,7 +4,7 @@ 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";
@@ -61,7 +61,17 @@ export function checkOptions(input) {
61
61
  fallback("tone", TONES.includes(raw.tone), `must be one of ${TONES.map((t) => `"${t}"`).join(", ")}`);
62
62
  fallback("challengeSentence", typeof raw.challengeSentence === "string" && raw.challengeSentence.trim() !== "", "must be a non-empty string");
63
63
  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 }");
64
+ fallback("agentContext", boolean(raw.agentContext) || (!!raw.agentContext && typeof raw.agentContext === "object" && !Array.isArray(raw.agentContext)), "must be true, false or { searchQuery, required }");
65
+ if (raw.agentContext && typeof raw.agentContext === "object" && !Array.isArray(raw.agentContext)) {
66
+ const context = raw.agentContext;
67
+ if (context.required === false)
68
+ warnings.push("agentContext.required: false is no longer supported; enabled context is required on paid requests. Set agentContext: false to disable context");
69
+ for (const key of ["required", "searchQuery"]) {
70
+ if (context[key] !== undefined && typeof context[key] !== "boolean") {
71
+ errors.push(`agentContext.${key} must be a boolean`);
72
+ }
73
+ }
74
+ }
65
75
  for (const key of ["feedback", "describeChallenges", "challengeExtension", "receiptExtension", "injectBody", "injectText", "strict"])
66
76
  fallback(key, boolean(raw[key]), "must be true or false");
67
77
  // The lines no wording may cross, whatever the merchant configures (see ask.ts).
@@ -97,6 +107,8 @@ export function checkOptions(input) {
97
107
  function disabledCore(errors, warnings) {
98
108
  const passThrough = {
99
109
  feedbackId: undefined,
110
+ contextRequired: false,
111
+ contextError: () => null,
100
112
  json: (_status, body) => body,
101
113
  text: (_status, _type, body) => body,
102
114
  headers: () => ({}),
@@ -165,6 +177,7 @@ function enabledCore(options, configWarnings) {
165
177
  const contextOption = options.agentContext ?? true;
166
178
  const collectContext = contextOption !== false;
167
179
  const searchQuery = collectContext && (typeof contextOption !== "object" || contextOption.searchQuery !== false);
180
+ const requiredContext = collectContext;
168
181
  const receipts = options.receiptExtension ?? true;
169
182
  const rateHint = options.rateHint === false
170
183
  ? null
@@ -194,10 +207,10 @@ function enabledCore(options, configWarnings) {
194
207
  const challengeMarker = challengeSentence.includes(rateUrl) ? rateUrl : challengeSentence;
195
208
  const challengeAdditions = {
196
209
  ...(describe ? { sentence: challengeSentence, marker: challengeMarker } : {}),
197
- ...(!feedback || options.challengeExtension === false ? {} : { extension: feedbackExtension(rateUrl, tone, collectContext ? agentContextAsk({ searchQuery }) : undefined) }),
210
+ ...(!feedback || options.challengeExtension === false ? {} : { extension: feedbackExtension(rateUrl, tone, collectContext ? agentContextAsk({ searchQuery, required: requiredContext }) : undefined) }),
198
211
  };
199
212
  if (!feedback && collectContext)
200
- challengeAdditions.contextExtension = { info: { agent_context: agentContextAsk({ searchQuery }), optional: true } };
213
+ challengeAdditions.contextExtension = { info: { agent_context: agentContextAsk({ searchQuery, required: requiredContext }), optional: !requiredContext } };
201
214
  const touchChallenges = Boolean(challengeAdditions.sentence || challengeAdditions.extension || challengeAdditions.contextExtension);
202
215
  // Set once a document has been enriched; lets body injection respect strict response schemas.
203
216
  let allowInjection = null;
@@ -212,7 +225,7 @@ function enabledCore(options, configWarnings) {
212
225
  isPaidOperation: openapi?.isPaidOperation,
213
226
  describeOperations: openapi?.describeOperations,
214
227
  hintField: Boolean(rateHint),
215
- agentContext: collectContext ? { searchQuery } : undefined,
228
+ agentContext: collectContext ? { searchQuery, required: requiredContext } : undefined,
216
229
  });
217
230
  stats.openapi = result.report;
218
231
  if (result.report.enriched)
@@ -342,6 +355,8 @@ function enabledCore(options, configWarnings) {
342
355
  }
343
356
  const passThrough = {
344
357
  feedbackId: undefined,
358
+ contextRequired: false,
359
+ contextError: () => null,
345
360
  json: (_status, body) => body,
346
361
  text: (_status, _type, body) => body,
347
362
  headers: () => ({}),
@@ -372,10 +387,13 @@ function enabledCore(options, configWarnings) {
372
387
  let bodyChallenge = false;
373
388
  let headerChallenge = false;
374
389
  let context;
390
+ let rawContext = {};
375
391
  let receiptFacts = {};
376
392
  const remember = (raw) => {
377
393
  if (!collectContext)
378
394
  return;
395
+ if (raw && typeof raw === "object" && !Array.isArray(raw))
396
+ rawContext = { ...rawContext, ...raw };
379
397
  const parsed = parseAgentContext(raw, { searchQuery });
380
398
  if (parsed)
381
399
  context = { ...context, ...parsed };
@@ -383,6 +401,18 @@ function enabledCore(options, configWarnings) {
383
401
  const ok = (status) => status >= 200 && status < 300;
384
402
  const handle = {
385
403
  feedbackId,
404
+ contextRequired: requiredContext && !!paymentHeader,
405
+ contextError() {
406
+ if (!handle.contextRequired)
407
+ return null;
408
+ const issues = contextIssues(rawContext, { searchQuery });
409
+ return issues.length ? {
410
+ status: 400, headers: {}, body: {
411
+ error: "agent_context_required", issues,
412
+ message: agentContextAsk({ searchQuery, required: true }),
413
+ },
414
+ } : null;
415
+ },
386
416
  json(status, body) {
387
417
  try {
388
418
  if (status === 402) {
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/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)
package/dist/openapi.d.ts CHANGED
@@ -2,7 +2,7 @@ type Json = Record<string, any>;
2
2
  export type SpecVersion = "2.0" | "3.0" | "3.1" | "3.2";
3
3
  export type ResponseSupport = "extended" | "incompatible" | "undocumented";
4
4
  export interface EnrichOptions {
5
- /** Disable all feedback additions while retaining optional agent context. */
5
+ /** Disable all feedback additions while retaining enabled agent context. */
6
6
  feedback?: boolean;
7
7
  /** Merchant public origin, e.g. https://api.example.com */
8
8
  publicUrl: string;
@@ -18,9 +18,10 @@ export interface EnrichOptions {
18
18
  describeOperations?: boolean;
19
19
  /** Also document the optional rate_this_call body field (when the SDK's rateHint is on). */
20
20
  hintField?: boolean;
21
- /** Document optional agent context on paid operations: `agent_context` in JSON request bodies, agent_* query parameters otherwise. */
21
+ /** Document agent context on paid operations: `agent_context` in JSON request bodies, agent_* query parameters otherwise. */
22
22
  agentContext?: {
23
23
  searchQuery: boolean;
24
+ required?: boolean;
24
25
  };
25
26
  }
26
27
  export interface OperationReport {
@@ -29,7 +30,7 @@ export interface OperationReport {
29
30
  path: string;
30
31
  /** Per 2xx status code: whether feedback_id was added to its JSON schema. */
31
32
  responses: Record<string, ResponseSupport>;
32
- /** Where optional agent context was documented, if asked to. */
33
+ /** Where enabled agent context was documented, if asked to. */
33
34
  agentContext?: "body" | "query" | "not_added";
34
35
  reasons: string[];
35
36
  }
package/dist/openapi.js CHANGED
@@ -100,8 +100,9 @@ function extendSchema(doc, schema, props) {
100
100
  return { schema: copy };
101
101
  }
102
102
  const BODYLESS = new Set(["get", "head", "delete", "options", "trace"]);
103
- /** Document optional agent context on one paid operation. Never required; skipped when a schema can't take it safely. */
103
+ /** Document agent context on a paid operation; skip schemas that cannot be extended safely. */
104
104
  function addAgentContext(doc, version, item, method, op, opts, report) {
105
+ opts = { ...opts, required: true }; // Enabled context cannot be made optional by a legacy option.
105
106
  const resolve = (v) => (isObj(v) && typeof v.$ref === "string" ? resolveRef(doc, v.$ref) : v);
106
107
  const listed = [...(Array.isArray(item.parameters) ? item.parameters : []), ...(Array.isArray(op.parameters) ? op.parameters : [])].map(resolve).filter(isObj);
107
108
  const props = { [CONTEXT_FIELD]: agentContextSchema(opts) };
@@ -115,8 +116,8 @@ function addAgentContext(doc, version, item, method, op, opts, report) {
115
116
  if (!params.length)
116
117
  return notAdded("parameter_collision");
117
118
  const documented = params.map((p) => version === "2.0"
118
- ? { name: p.name, in: "query", required: false, description: p.description, ...p.schema }
119
- : { name: p.name, in: "query", required: false, description: p.description, schema: p.schema });
119
+ ? { name: p.name, in: "query", required: !!opts.required && p.name !== "agent_type_other", description: p.description, ...p.schema }
120
+ : { name: p.name, in: "query", required: !!opts.required && p.name !== "agent_type_other", description: p.description, schema: p.schema });
120
121
  op.parameters = [...(Array.isArray(op.parameters) ? op.parameters : []), ...documented];
121
122
  report.agentContext = "query";
122
123
  return;
@@ -130,6 +131,10 @@ function addAgentContext(doc, version, item, method, op, opts, report) {
130
131
  const result = extendSchema(doc, param.schema, props);
131
132
  if ("reason" in result)
132
133
  return notAdded(result.reason);
134
+ if (opts.required) {
135
+ result.schema.required = [...new Set([...(Array.isArray(result.schema.required) ? result.schema.required : []), CONTEXT_FIELD])];
136
+ param.required = true;
137
+ }
133
138
  param.schema = result.schema;
134
139
  op.parameters = params.map((p, i) => (i === index ? param : p)); // inline copy if it was a shared $ref
135
140
  report.agentContext = "body";
@@ -146,8 +151,12 @@ function addAgentContext(doc, version, item, method, op, opts, report) {
146
151
  const result = extendSchema(doc, entry.schema, props);
147
152
  if ("reason" in result)
148
153
  return notAdded(result.reason);
154
+ if (opts.required)
155
+ result.schema.required = [...new Set([...(Array.isArray(result.schema.required) ? result.schema.required : []), CONTEXT_FIELD])];
149
156
  entry.schema = result.schema;
150
157
  }
158
+ if (opts.required)
159
+ copy.required = true;
151
160
  op.requestBody = copy; // inline copy if it was a shared $ref
152
161
  report.agentContext = "body";
153
162
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@forgeintel/sdk",
3
- "version": "0.5.0-beta.2",
4
- "description": "The Forge SDK for x402 paid APIs: agent feedback (feedback IDs, one-request GET ratings), agent context, OpenAPI and challenge enrichment, and passive call signals. Express, Hono, Next.js and any fetch handler. Never on your critical path.",
3
+ "version": "0.5.0-beta.4",
4
+ "description": "The Forge SDK for x402 paid APIs: agent feedback, required agent context, OpenAPI and challenge enrichment, and passive call signals. Express, Hono, Next.js and fetch handlers. No Forge network request on the merchant response path.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "engines": {