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

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/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,10 +8,11 @@ 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";
15
15
  export type { AskTexts, Tone } from "./ask.js";
16
16
  export type { ChallengeAdditions } from "./x402.js";
17
17
  export type { ForgeEvent } from "./reporter.js";
18
+ export type { ClientHeaders } from "./client-signals.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)
package/dist/openapi.d.ts CHANGED
@@ -2,8 +2,10 @@ 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
- /** Merchant public origin, e.g. https://api.example.com */
6
- publicUrl: string;
5
+ /** Disable all feedback additions while retaining enabled agent context. */
6
+ feedback?: boolean;
7
+ /** Optional merchant public origin. Without it, feedback links use same-origin paths. */
8
+ publicUrl?: string;
7
9
  /** Public path of the feedback routes, e.g. /feedback */
8
10
  basePath: string;
9
11
  /** Sentence appended to x-guidance and paid operation descriptions. */
@@ -16,9 +18,10 @@ export interface EnrichOptions {
16
18
  describeOperations?: boolean;
17
19
  /** Also document the optional rate_this_call body field (when the SDK's rateHint is on). */
18
20
  hintField?: boolean;
19
- /** 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. */
20
22
  agentContext?: {
21
23
  searchQuery: boolean;
24
+ required?: boolean;
22
25
  };
23
26
  }
24
27
  export interface OperationReport {
@@ -27,7 +30,7 @@ export interface OperationReport {
27
30
  path: string;
28
31
  /** Per 2xx status code: whether feedback_id was added to its JSON schema. */
29
32
  responses: Record<string, ResponseSupport>;
30
- /** Where optional agent context was documented, if asked to. */
33
+ /** Where enabled agent context was documented, if asked to. */
31
34
  agentContext?: "body" | "query" | "not_added";
32
35
  reasons: string[];
33
36
  }
package/dist/openapi.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // and on any unexpected input return the original document unchanged.
4
4
  import { CONTEXT_FIELD, agentContextParameters, agentContextSchema } from "./context.js";
5
5
  import { FEEDBACK_ID_PATTERN } from "./id.js";
6
+ import { FEEDBACK_FIELD } from "./x402.js";
6
7
  import { ISSUES, NOTE_MAX_LENGTH, OUTCOMES, PROTOCOL } from "./values.js";
7
8
  const METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
8
9
  const ANNOTATIONS = new Set(["$ref", "description", "title", "summary", "example", "examples", "deprecated", "readOnly", "writeOnly", "$comment"]);
@@ -38,16 +39,23 @@ function resolveRef(doc, ref) {
38
39
  }
39
40
  function feedbackProperties(rateUrl, hintField = false) {
40
41
  return {
41
- ...(hintField ? { rate_this_call: { type: "string", description: "How to rate this paid call in one free request." } } : {}),
42
- feedback_id: {
43
- type: "string",
44
- pattern: FEEDBACK_ID_PATTERN.source,
45
- description: "Rating ID for this paid call (also sent as the Forge-Feedback-Id header). See x-forge-feedback.",
46
- },
47
- feedback_url: {
48
- type: "string",
49
- format: "uri",
50
- description: `Quick-rating URL with outcome left blank; outcome values: ${Object.keys(OUTCOMES).join(", ")}. Base: ${rateUrl}`,
42
+ [FEEDBACK_FIELD]: {
43
+ type: "object",
44
+ description: "Added by Forge to paid responses: how to rate this call. See x-forge-feedback.",
45
+ required: ["feedback_id", "feedback_url"],
46
+ properties: {
47
+ feedback_id: {
48
+ type: "string",
49
+ pattern: FEEDBACK_ID_PATTERN.source,
50
+ description: "Rating ID for this paid call (also sent as the Forge-Feedback-Id header).",
51
+ },
52
+ feedback_url: {
53
+ type: "string",
54
+ format: "uri-reference",
55
+ description: `Quick-rating URL with outcome left blank; outcome values: ${Object.keys(OUTCOMES).join(", ")}. Base: ${rateUrl}`,
56
+ },
57
+ ...(hintField ? { rate_this_call: { type: "string", description: "How to rate this paid call in one free request." } } : {}),
58
+ },
51
59
  },
52
60
  };
53
61
  }
@@ -100,8 +108,9 @@ function extendSchema(doc, schema, props) {
100
108
  return { schema: copy };
101
109
  }
102
110
  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. */
111
+ /** Document agent context on a paid operation; skip schemas that cannot be extended safely. */
104
112
  function addAgentContext(doc, version, item, method, op, opts, report) {
113
+ opts = { ...opts, required: true }; // Enabled context cannot be made optional by a legacy option.
105
114
  const resolve = (v) => (isObj(v) && typeof v.$ref === "string" ? resolveRef(doc, v.$ref) : v);
106
115
  const listed = [...(Array.isArray(item.parameters) ? item.parameters : []), ...(Array.isArray(op.parameters) ? op.parameters : [])].map(resolve).filter(isObj);
107
116
  const props = { [CONTEXT_FIELD]: agentContextSchema(opts) };
@@ -115,8 +124,8 @@ function addAgentContext(doc, version, item, method, op, opts, report) {
115
124
  if (!params.length)
116
125
  return notAdded("parameter_collision");
117
126
  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 });
127
+ ? { name: p.name, in: "query", required: !!opts.required && p.name !== "agent_type_other", description: p.description, ...p.schema }
128
+ : { name: p.name, in: "query", required: !!opts.required && p.name !== "agent_type_other", description: p.description, schema: p.schema });
120
129
  op.parameters = [...(Array.isArray(op.parameters) ? op.parameters : []), ...documented];
121
130
  report.agentContext = "query";
122
131
  return;
@@ -130,6 +139,10 @@ function addAgentContext(doc, version, item, method, op, opts, report) {
130
139
  const result = extendSchema(doc, param.schema, props);
131
140
  if ("reason" in result)
132
141
  return notAdded(result.reason);
142
+ if (opts.required) {
143
+ result.schema.required = [...new Set([...(Array.isArray(result.schema.required) ? result.schema.required : []), CONTEXT_FIELD])];
144
+ param.required = true;
145
+ }
133
146
  param.schema = result.schema;
134
147
  op.parameters = params.map((p, i) => (i === index ? param : p)); // inline copy if it was a shared $ref
135
148
  report.agentContext = "body";
@@ -146,8 +159,12 @@ function addAgentContext(doc, version, item, method, op, opts, report) {
146
159
  const result = extendSchema(doc, entry.schema, props);
147
160
  if ("reason" in result)
148
161
  return notAdded(result.reason);
162
+ if (opts.required)
163
+ result.schema.required = [...new Set([...(Array.isArray(result.schema.required) ? result.schema.required : []), CONTEXT_FIELD])];
149
164
  entry.schema = result.schema;
150
165
  }
166
+ if (opts.required)
167
+ copy.required = true;
151
168
  op.requestBody = copy; // inline copy if it was a shared $ref
152
169
  report.agentContext = "body";
153
170
  }
@@ -161,20 +178,21 @@ function appendSentence(text, sentence, marker) {
161
178
  }
162
179
  /** Work out the path prefix document paths are relative to, and whether it's this service's origin. */
163
180
  function serverBase(doc, version, publicUrl) {
164
- const origin = new URL(publicUrl).origin;
181
+ const origin = publicUrl ? new URL(publicUrl).origin : undefined;
182
+ const baseUrl = publicUrl ?? "https://forge.invalid";
165
183
  try {
166
184
  if (version === "2.0") {
167
- const scheme = Array.isArray(doc.schemes) && doc.schemes[0] ? doc.schemes[0] : new URL(publicUrl).protocol.slice(0, -1);
185
+ const scheme = Array.isArray(doc.schemes) && doc.schemes[0] ? doc.schemes[0] : new URL(baseUrl).protocol.slice(0, -1);
168
186
  const base = typeof doc.basePath === "string" ? doc.basePath : "";
169
- const url = typeof doc.host === "string" ? new URL(`${scheme}://${doc.host}${base}`) : new URL(base || "/", publicUrl);
170
- return { prefix: url.pathname.replace(/\/+$/, ""), sameOrigin: url.origin === origin };
187
+ const url = typeof doc.host === "string" ? new URL(`${scheme}://${doc.host}${base}`) : new URL(base || "/", baseUrl);
188
+ return { prefix: url.pathname.replace(/\/+$/, ""), sameOrigin: !origin || url.origin === origin };
171
189
  }
172
190
  const server = Array.isArray(doc.servers) ? doc.servers[0] : undefined;
173
191
  if (!isObj(server) || typeof server.url !== "string")
174
192
  return { prefix: "", sameOrigin: true };
175
193
  const raw = server.url.replace(/\{([^}]+)\}/g, (_, name) => server.variables?.[name]?.default ?? "");
176
- const url = new URL(raw, publicUrl);
177
- return { prefix: url.pathname.replace(/\/+$/, ""), sameOrigin: url.origin === origin };
194
+ const url = new URL(raw, baseUrl);
195
+ return { prefix: url.pathname.replace(/\/+$/, ""), sameOrigin: !origin || url.origin === origin };
178
196
  }
179
197
  catch {
180
198
  return { prefix: "", sameOrigin: false };
@@ -253,7 +271,7 @@ export function enrichOpenApi(input, options) {
253
271
  }
254
272
  try {
255
273
  const doc = clone(original);
256
- const origin = new URL(options.publicUrl).origin;
274
+ const origin = options.publicUrl ? new URL(options.publicUrl).origin : "";
257
275
  const base = options.basePath.replace(/\/+$/, "");
258
276
  const rateUrl = `${origin}${base}/rate`;
259
277
  const formUrl = `${origin}${base}`;
@@ -287,6 +305,11 @@ export function enrichOpenApi(input, options) {
287
305
  continue;
288
306
  const opReport = { method: method.toUpperCase(), path, responses: {}, reasons: [] };
289
307
  report.operations.push(opReport);
308
+ if (options.feedback === false) {
309
+ if (options.agentContext)
310
+ addAgentContext(doc, version, item, method, op, options.agentContext, opReport);
311
+ continue;
312
+ }
290
313
  if (options.describeOperations ?? true)
291
314
  op.description = appendSentence(op.description, options.sentence, options.marker);
292
315
  const produces = (op.produces ?? doc.produces);
@@ -344,6 +367,10 @@ export function enrichOpenApi(input, options) {
344
367
  }
345
368
  }
346
369
  }
370
+ if (options.feedback === false) {
371
+ report.enriched = Boolean(options.agentContext);
372
+ return { document: doc, report };
373
+ }
347
374
  // Feedback routes. Paths are relative to the server prefix when it's this origin;
348
375
  // otherwise (3.x) the path items carry their own servers entry.
349
376
  const inPrefix = sameOrigin && (base === prefix || base.startsWith(`${prefix}/`));
@@ -359,7 +386,7 @@ export function enrichOpenApi(input, options) {
359
386
  }
360
387
  else {
361
388
  const ops = feedbackOperations(version, formUrl, rateUrl, operationIds);
362
- const servers = inPrefix ? {} : { servers: [{ url: origin }] };
389
+ const servers = inPrefix ? {} : { servers: [{ url: origin || "/" }] };
363
390
  paths[docBase] = { ...servers, get: ops.form, post: ops.submit };
364
391
  paths[`${docBase}/rate`] = { ...servers, get: ops.rate };
365
392
  paths[`${docBase}/summary`] = { ...servers, get: ops.summary };
@@ -1,13 +1,23 @@
1
1
  import type { AgentContext } from "./context.js";
2
+ import type { ClientHeaders } from "./client-signals.js";
2
3
  export type ForgeEvent = {
4
+ type: "discovery";
5
+ route: string;
6
+ ts: number;
7
+ user_agent?: string;
8
+ request_headers?: ClientHeaders;
9
+ } | {
3
10
  type: "challenge";
4
11
  route: string;
5
12
  ts: number;
6
13
  user_agent?: string;
14
+ request_headers?: ClientHeaders;
7
15
  agent_context?: AgentContext;
8
16
  } | {
9
17
  type: "interaction";
10
- feedback_id: string;
18
+ feedback_id?: string;
19
+ /** Independent telemetry identifier when feedback is disabled. */
20
+ interaction_id?: string;
11
21
  route: string;
12
22
  status: number;
13
23
  latency_ms: number;
@@ -19,6 +29,7 @@ export type ForgeEvent = {
19
29
  /** Settlement transaction reference from the receipt. */
20
30
  transaction?: string;
21
31
  user_agent?: string;
32
+ request_headers?: ClientHeaders;
22
33
  agent_context?: AgentContext;
23
34
  ts: number;
24
35
  };
package/dist/x402.d.ts CHANGED
@@ -1,16 +1,29 @@
1
1
  import { type Tone } from "./ask.js";
2
+ import type { ContextOptions } from "./context.js";
2
3
  /** Key of the Forge extension in x402 v2 `extensions`: in the 402 challenge (next to e.g. `bazaar`) and in the payment receipt. */
3
4
  export declare const FEEDBACK_EXTENSION = "forge-feedback";
5
+ /** Key of the object the SDK adds to paid JSON response bodies: feedback_id, feedback_url, and optionally rate_this_call. */
6
+ export declare const FEEDBACK_FIELD = "forge_feedback";
7
+ /**
8
+ * The service origin a challenge was issued for: v2 `resource.url` (header or body), or v1 `accepts[].resource`.
9
+ * Used for absolute rating links when no origin is configured or registered. Never throws.
10
+ */
11
+ export declare function challengeOrigin(challenge: string | unknown): string | undefined;
12
+ /** The service origin a payment was made for (x402 v2 `resource.url` in the payment header). Never throws. */
13
+ export declare function paymentOrigin(headerValue: string): string | undefined;
4
14
  /**
5
15
  * The `forge-feedback` challenge extension: how to rate the call, as structured data. Clients that inspect
6
16
  * the 402 (e.g. `awal x402 details`) print every extension, so this reaches agents before they pay.
7
- * With `agentContext`, it also says how to report agent context with the paid request.
17
+ * Agent context is a separate switch with its own extension (`forge-agent-context`).
8
18
  */
9
- export declare function feedbackExtension(rateUrl: string, tone?: Tone, agentContext?: string): {
19
+ export declare function feedbackExtension(rateUrl: string, tone?: Tone): {
10
20
  info: {
11
- agent_context?: string | undefined;
12
21
  rate: string;
13
22
  outcome: string[];
23
+ issue: {
24
+ when: string;
25
+ values: string[];
26
+ };
14
27
  feedback_id: string;
15
28
  payment: string;
16
29
  protocol: string;
@@ -25,6 +38,10 @@ export declare function receiptExtension(rateUrl: string, feedbackId: string, to
25
38
  feedback_id: string;
26
39
  rate: string;
27
40
  outcome: string[];
41
+ issue: {
42
+ when: string;
43
+ values: string[];
44
+ };
28
45
  payment: string;
29
46
  };
30
47
  };
@@ -34,19 +51,30 @@ export declare function receiptExtension(rateUrl: string, feedbackId: string, to
34
51
  * Returns undefined when the header can't be parsed, settlement failed, or the extension is already there.
35
52
  */
36
53
  export declare function describeReceipt(headerValue: string, extension: unknown): string | undefined;
54
+ /**
55
+ * The longest challenge description the SDK produces. Clients copy the description into the payment payload,
56
+ * and the CDP facilitator rejects a payload whose resource.description exceeds 500 characters, failing the payment.
57
+ */
58
+ export declare const MAX_DESCRIPTION = 500;
37
59
  /** What the SDK adds to a challenge. Each part is skipped when already present. */
38
60
  export interface ChallengeAdditions {
39
61
  /** Appended to the description (v2 resource.description, v1 accepts[].description). */
40
62
  sentence?: string;
63
+ /** Appended instead when `sentence` would pass MAX_DESCRIPTION; the description is left alone when this doesn't fit either. */
64
+ shortSentence?: string;
41
65
  /** Idempotency marker for the sentence. Default: the sentence itself. */
42
66
  marker?: string;
43
67
  /** Added as extensions["forge-feedback"] (v2 only; v1 challenges have no extensions). */
44
68
  extension?: unknown;
69
+ /** Added as extensions["forge-agent-context"] whenever agent context is enabled, independent of feedback. */
70
+ contextExtension?: unknown;
71
+ /** Declare agent context in the merchant's `bazaar` extension (schema and example together). */
72
+ bazaarContext?: ContextOptions;
45
73
  }
46
74
  /**
47
- * Add the rating sentence and/or the forge-feedback extension to a base64 PAYMENT-REQUIRED header (x402 v2).
48
- * `accepts` is untouched: v2 matches payments on `accepts`, and @x402/core only checks echoed extensions
49
- * the server itself advertised, so an added extension doesn't affect payment.
75
+ * Add the rating sentence, Forge's extensions and Bazaar agent context to a base64 PAYMENT-REQUIRED header (x402 v2).
76
+ * `accepts` is untouched: v2 matches payments on `accepts`. @x402/core checks that each echoed extension's `info`
77
+ * contains what the server advertised, so added extensions and added Bazaar fields don't affect payment.
50
78
  * Returns undefined when the header can't be parsed or already has everything.
51
79
  */
52
80
  export declare function describeChallenge(headerValue: string, additions: ChallengeAdditions): string | undefined;
package/dist/x402.js CHANGED
@@ -1,13 +1,48 @@
1
1
  import { ASK } from "./ask.js";
2
- import { PROTOCOL } from "./values.js";
2
+ import { addContextToBazaar } from "./bazaar.js";
3
+ import { ISSUES, PROTOCOL } from "./values.js";
3
4
  /** Key of the Forge extension in x402 v2 `extensions`: in the 402 challenge (next to e.g. `bazaar`) and in the payment receipt. */
4
5
  export const FEEDBACK_EXTENSION = "forge-feedback";
6
+ /** Key of the object the SDK adds to paid JSON response bodies: feedback_id, feedback_url, and optionally rate_this_call. */
7
+ export const FEEDBACK_FIELD = "forge_feedback";
8
+ const originOf = (url) => {
9
+ try {
10
+ const parsed = new URL(String(url));
11
+ return parsed.protocol === "https:" || parsed.protocol === "http:" ? parsed.origin : undefined;
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ };
17
+ /**
18
+ * The service origin a challenge was issued for: v2 `resource.url` (header or body), or v1 `accepts[].resource`.
19
+ * Used for absolute rating links when no origin is configured or registered. Never throws.
20
+ */
21
+ export function challengeOrigin(challenge) {
22
+ try {
23
+ const value = typeof challenge === "string" ? JSON.parse(Buffer.from(challenge, "base64").toString("utf8")) : challenge;
24
+ const resource = value?.resource?.url ?? (Array.isArray(value?.accepts) ? value.accepts[0]?.resource : undefined);
25
+ return originOf(resource);
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ }
31
+ /** The service origin a payment was made for (x402 v2 `resource.url` in the payment header). Never throws. */
32
+ export function paymentOrigin(headerValue) {
33
+ try {
34
+ return originOf(JSON.parse(Buffer.from(headerValue, "base64").toString("utf8"))?.resource?.url);
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
5
40
  /**
6
41
  * The `forge-feedback` challenge extension: how to rate the call, as structured data. Clients that inspect
7
42
  * the 402 (e.g. `awal x402 details`) print every extension, so this reaches agents before they pay.
8
- * With `agentContext`, it also says how to report agent context with the paid request.
43
+ * Agent context is a separate switch with its own extension (`forge-agent-context`).
9
44
  */
10
- export function feedbackExtension(rateUrl, tone = "soft", agentContext) {
45
+ export function feedbackExtension(rateUrl, tone = "soft") {
11
46
  return {
12
47
  info: {
13
48
  protocol: PROTOCOL,
@@ -15,9 +50,9 @@ export function feedbackExtension(rateUrl, tone = "soft", agentContext) {
15
50
  ...ASK[tone].extensionExtras,
16
51
  rate: `GET ${rateUrl}?feedback_id=FEEDBACK_ID&outcome=fully`,
17
52
  outcome: ["fully", "partially", "no"],
18
- feedback_id: "In the paid response body (feedback_id) and the Forge-Feedback-Id header.",
53
+ issue: { when: "The outcome is not fully. Tells the merchant what to fix.", values: Object.keys(ISSUES) },
54
+ feedback_id: `In the paid response body (${FEEDBACK_FIELD}.feedback_id) and the Forge-Feedback-Id header.`,
19
55
  payment: "None. Plain GET, not an x402 endpoint.",
20
- ...(agentContext ? { agent_context: agentContext } : {}),
21
56
  },
22
57
  };
23
58
  }
@@ -30,6 +65,7 @@ export function receiptExtension(rateUrl, feedbackId, tone = "soft") {
30
65
  feedback_id: feedbackId,
31
66
  rate: `GET ${rateUrl}?feedback_id=${feedbackId}&outcome=fully`,
32
67
  outcome: ["fully", "partially", "no"],
68
+ issue: { when: "The outcome is not fully. Tells the merchant what to fix.", values: Object.keys(ISSUES) },
33
69
  payment: "None. Plain GET, not an x402 endpoint.",
34
70
  },
35
71
  };
@@ -55,39 +91,59 @@ export function describeReceipt(headerValue, extension) {
55
91
  receipt.extensions = { ...extensions, [FEEDBACK_EXTENSION]: extension };
56
92
  return Buffer.from(JSON.stringify(receipt), "utf8").toString("base64");
57
93
  }
58
- function appendSentence(description, sentence, marker) {
94
+ /**
95
+ * The longest challenge description the SDK produces. Clients copy the description into the payment payload,
96
+ * and the CDP facilitator rejects a payload whose resource.description exceeds 500 characters, failing the payment.
97
+ */
98
+ export const MAX_DESCRIPTION = 500;
99
+ function appendSentence(description, sentence, marker, shortSentence) {
59
100
  const current = typeof description === "string" ? description.trim() : "";
60
- return current.includes(marker) ? current : current ? `${current} ${sentence}` : sentence;
101
+ if (current.includes(marker))
102
+ return current;
103
+ for (const candidate of [sentence, shortSentence]) {
104
+ if (!candidate)
105
+ continue;
106
+ const next = current ? `${current} ${candidate}` : candidate;
107
+ if (next.length <= MAX_DESCRIPTION)
108
+ return next;
109
+ }
110
+ // Nothing fits: the forge-feedback extension still carries the ask.
111
+ return current;
61
112
  }
62
113
  /** Apply additions to a v2 PaymentRequired in place. Returns whether anything changed. */
63
114
  function addToV2(challenge, add) {
64
115
  let changed = false;
65
116
  const resource = challenge.resource;
66
117
  if (add.sentence && resource && typeof resource === "object") {
67
- const next = appendSentence(resource.description, add.sentence, add.marker ?? add.sentence);
118
+ const next = appendSentence(resource.description, add.sentence, add.marker ?? add.sentence, add.shortSentence);
68
119
  if (next !== resource.description) {
69
120
  resource.description = next;
70
121
  changed = true;
71
122
  }
72
123
  }
73
- if (add.extension) {
124
+ for (const [key, extension] of [[FEEDBACK_EXTENSION, add.extension], ["forge-agent-context", add.contextExtension]]) {
125
+ if (!extension)
126
+ continue;
74
127
  const extensions = challenge.extensions;
75
128
  if (extensions === undefined || extensions === null) {
76
- challenge.extensions = { [FEEDBACK_EXTENSION]: add.extension };
129
+ challenge.extensions = { [key]: extension };
77
130
  changed = true;
78
131
  }
79
- else if (typeof extensions === "object" && !Array.isArray(extensions) && !(FEEDBACK_EXTENSION in extensions)) {
80
- // Added last, so the merchant's own extensions (e.g. bazaar) keep their order and content.
81
- challenge.extensions = { ...extensions, [FEEDBACK_EXTENSION]: add.extension };
132
+ else if (typeof extensions === "object" && !Array.isArray(extensions) && !(key in extensions)) {
133
+ // Added last, so the merchant's own extensions (e.g. bazaar) keep their order.
134
+ challenge.extensions = { ...extensions, [key]: extension };
82
135
  changed = true;
83
136
  }
84
137
  }
138
+ const extensions = challenge.extensions;
139
+ if (add.bazaarContext && extensions && typeof extensions === "object" && addContextToBazaar(extensions.bazaar, add.bazaarContext))
140
+ changed = true;
85
141
  return changed;
86
142
  }
87
143
  /**
88
- * Add the rating sentence and/or the forge-feedback extension to a base64 PAYMENT-REQUIRED header (x402 v2).
89
- * `accepts` is untouched: v2 matches payments on `accepts`, and @x402/core only checks echoed extensions
90
- * the server itself advertised, so an added extension doesn't affect payment.
144
+ * Add the rating sentence, Forge's extensions and Bazaar agent context to a base64 PAYMENT-REQUIRED header (x402 v2).
145
+ * `accepts` is untouched: v2 matches payments on `accepts`. @x402/core checks that each echoed extension's `info`
146
+ * contains what the server advertised, so added extensions and added Bazaar fields don't affect payment.
91
147
  * Returns undefined when the header can't be parsed or already has everything.
92
148
  */
93
149
  export function describeChallenge(headerValue, additions) {
@@ -120,7 +176,7 @@ export function describeChallengeBody(body, additions) {
120
176
  const marker = additions.marker ?? sentence;
121
177
  return {
122
178
  ...b,
123
- accepts: b.accepts.map((a) => a && typeof a === "object" ? { ...a, description: appendSentence(a.description, sentence, marker) } : a),
179
+ accepts: b.accepts.map((a) => a && typeof a === "object" ? { ...a, description: appendSentence(a.description, sentence, marker, additions.shortSentence) } : a),
124
180
  };
125
181
  }
126
182
  if (b.x402Version === 2 && b.resource && typeof b.resource === "object") {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@forgeintel/sdk",
3
- "version": "0.4.0-beta.0",
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-alpha.oldfeedback.1",
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": {
@@ -73,7 +73,7 @@
73
73
  },
74
74
  "publishConfig": {
75
75
  "access": "public",
76
- "tag": "beta"
76
+ "tag": "latest"
77
77
  },
78
78
  "peerDependencies": {
79
79
  "express": ">=4.21 <6",