@forgeintel/sdk 0.4.0-beta.1 → 0.5.0-alpha.oldfeedback.1.b13
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 +36 -17
- package/dist/ask.d.ts +2 -0
- package/dist/ask.js +2 -0
- package/dist/bazaar.d.ts +7 -0
- package/dist/bazaar.js +58 -0
- package/dist/client-signals.d.ts +4 -0
- package/dist/client-signals.js +45 -0
- package/dist/context.d.ts +34 -22
- package/dist/context.js +67 -27
- package/dist/core.d.ts +20 -9
- package/dist/core.js +186 -71
- package/dist/express.js +37 -6
- package/dist/fetch.d.ts +3 -1
- package/dist/fetch.js +77 -8
- package/dist/hono.js +1 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/next.js +4 -0
- package/dist/openapi.d.ts +7 -4
- package/dist/openapi.js +53 -22
- package/dist/reporter.d.ts +12 -1
- package/dist/x402.d.ts +26 -6
- package/dist/x402.js +70 -16
- package/package.json +4 -8
package/dist/core.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
// Framework-free Forge logic. Adapters (express.ts, later fetch.ts) translate their request/response
|
|
2
3
|
// objects to the small interfaces below and write out what the core returns.
|
|
3
4
|
import { DEFAULT_TTL_MS, deriveSigningKey, mintFeedbackId, verifyFeedbackId } from "./id.js";
|
|
4
5
|
import { createOperationIndex, enrichOpenApi } from "./openapi.js";
|
|
5
6
|
import { ASK, TONES, checkAskText } from "./ask.js";
|
|
6
|
-
import { agentContextAsk, parseAgentContext, takeFromBody, takeFromUrl } from "./context.js";
|
|
7
|
+
import { agentContextAsk, contextIssues, parseAgentContext, takeFromBody, takeFromUrl } from "./context.js";
|
|
7
8
|
import { EventReporter } from "./reporter.js";
|
|
9
|
+
import { captureClientHeaders } from "./client-signals.js";
|
|
8
10
|
import { ISSUES, NOTE_MAX_LENGTH, OUTCOMES, PROTOCOL, parseSubmission } from "./values.js";
|
|
9
|
-
import { describeChallenge, describeChallengeBody, describeReceipt, feedbackExtension, readPaymentHeader, readReceipt, receiptExtension } from "./x402.js";
|
|
11
|
+
import { FEEDBACK_FIELD, challengeOrigin, describeChallenge, describeChallengeBody, describeReceipt, feedbackExtension, paymentOrigin, readPaymentHeader, readReceipt, receiptExtension } from "./x402.js";
|
|
10
12
|
export const FEEDBACK_HEADERS = { "Cache-Control": "no-store", "X-Robots-Tag": "noindex" };
|
|
11
13
|
/** Max JSON body for POST {basePath}. */
|
|
12
14
|
export const BODY_LIMIT = 8 * 1024;
|
|
13
15
|
/** Max OpenAPI document an adapter should buffer for enrichment. */
|
|
14
16
|
export const SPEC_LIMIT = 10 * 1024 * 1024;
|
|
17
|
+
export const DEFAULT_BACKEND_URL = "https://app-api.forgeintel.co/api/sdk/v2";
|
|
15
18
|
/**
|
|
16
19
|
* Check options without throwing. Invalid required options are errors (Forge runs disabled);
|
|
17
20
|
* invalid optional values are warnings and fall back to their defaults.
|
|
@@ -34,10 +37,12 @@ export function checkOptions(input) {
|
|
|
34
37
|
};
|
|
35
38
|
if (typeof raw.apiKey !== "string" || !raw.apiKey.trim())
|
|
36
39
|
errors.push("apiKey is missing (set it to your merchant key, ffk_…)");
|
|
37
|
-
if (
|
|
40
|
+
if (raw.backendUrl === undefined)
|
|
41
|
+
options.backendUrl = DEFAULT_BACKEND_URL;
|
|
42
|
+
else if (!absoluteUrl(raw.backendUrl))
|
|
38
43
|
errors.push(`backendUrl must be an absolute http(s) URL (got ${JSON.stringify(raw.backendUrl ?? null)})`);
|
|
39
|
-
if (!absoluteUrl(raw.publicUrl))
|
|
40
|
-
errors.push(`publicUrl must be an absolute http(s) URL
|
|
44
|
+
if (raw.publicUrl !== undefined && !absoluteUrl(raw.publicUrl))
|
|
45
|
+
errors.push(`publicUrl must be an absolute http(s) URL (got ${JSON.stringify(raw.publicUrl ?? null)})`);
|
|
41
46
|
const fallback = (key, ok, expected) => {
|
|
42
47
|
if (raw[key] === undefined || ok)
|
|
43
48
|
return;
|
|
@@ -59,8 +64,16 @@ export function checkOptions(input) {
|
|
|
59
64
|
fallback("tone", TONES.includes(raw.tone), `must be one of ${TONES.map((t) => `"${t}"`).join(", ")}`);
|
|
60
65
|
fallback("challengeSentence", typeof raw.challengeSentence === "string" && raw.challengeSentence.trim() !== "", "must be a non-empty string");
|
|
61
66
|
fallback("rateHint", boolean(raw.rateHint) || typeof raw.rateHint === "string", "must be a boolean or a string");
|
|
62
|
-
fallback("agentContext", boolean(raw.agentContext) || (!!raw.agentContext && typeof raw.agentContext === "object" && !Array.isArray(raw.agentContext)), "must be true, false or { searchQuery }");
|
|
63
|
-
|
|
67
|
+
fallback("agentContext", boolean(raw.agentContext) || (!!raw.agentContext && typeof raw.agentContext === "object" && !Array.isArray(raw.agentContext)), "must be true, false or { searchQuery, required }");
|
|
68
|
+
if (raw.agentContext && typeof raw.agentContext === "object" && !Array.isArray(raw.agentContext)) {
|
|
69
|
+
const context = raw.agentContext;
|
|
70
|
+
for (const key of ["required", "searchQuery"]) {
|
|
71
|
+
if (context[key] !== undefined && typeof context[key] !== "boolean") {
|
|
72
|
+
errors.push(`agentContext.${key} must be a boolean`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const key of ["feedback", "describeChallenges", "challengeExtension", "receiptExtension", "injectBody", "injectText", "strict"])
|
|
64
77
|
fallback(key, boolean(raw[key]), "must be true or false");
|
|
65
78
|
// The lines no wording may cross, whatever the merchant configures (see ask.ts).
|
|
66
79
|
for (const key of ["challengeSentence", "rateHint"]) {
|
|
@@ -91,15 +104,20 @@ export function checkOptions(input) {
|
|
|
91
104
|
}
|
|
92
105
|
return { errors, warnings, options };
|
|
93
106
|
}
|
|
94
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* Used when invalid options turned Forge off: nothing is recorded or added, but agent context is still removed from
|
|
109
|
+
* requests, so agents that learned about it (an earlier deploy, a cached listing) can't trip strict validators.
|
|
110
|
+
*/
|
|
95
111
|
function disabledCore(errors, warnings) {
|
|
96
112
|
const passThrough = {
|
|
97
113
|
feedbackId: undefined,
|
|
114
|
+
contextRequired: false,
|
|
115
|
+
contextError: () => null,
|
|
98
116
|
json: (_status, body) => body,
|
|
99
117
|
text: (_status, _type, body) => body,
|
|
100
118
|
headers: () => ({}),
|
|
101
|
-
requestBody: (body) => body,
|
|
102
|
-
requestUrl: (url) => url,
|
|
119
|
+
requestBody: (body) => takeFromBody(body).body,
|
|
120
|
+
requestUrl: (url) => takeFromUrl(url).url,
|
|
103
121
|
finish: () => { },
|
|
104
122
|
};
|
|
105
123
|
return {
|
|
@@ -142,26 +160,36 @@ export function createForgeCore(input) {
|
|
|
142
160
|
}
|
|
143
161
|
}
|
|
144
162
|
function enabledCore(options, configWarnings) {
|
|
145
|
-
const { apiKey,
|
|
163
|
+
const { apiKey, publicUrl } = options;
|
|
164
|
+
const backendUrl = options.backendUrl ?? DEFAULT_BACKEND_URL;
|
|
146
165
|
// Feedback IDs are signed with a key derived from the API key, never with the API key itself.
|
|
147
166
|
const signingKey = deriveSigningKey(apiKey);
|
|
148
167
|
const backendPath = new URL(backendUrl).pathname.replace(/\/+$/, "") || "/v1";
|
|
149
168
|
const backend = (name) => new URL(`${backendPath}/${name}`, backendUrl).href;
|
|
150
169
|
const basePath = (options.basePath ?? "/feedback").replace(/\/+$/, "");
|
|
151
170
|
const ratePath = `${basePath}/rate`;
|
|
152
|
-
const rateUrl = new URL(ratePath, publicUrl).href;
|
|
153
|
-
const formUrl = new URL(basePath, publicUrl).href;
|
|
154
171
|
const summaryPath = `${basePath}/summary`;
|
|
155
|
-
|
|
172
|
+
// Rating links are absolute whenever an origin is known: publicUrl, else the origin registered in Forge
|
|
173
|
+
// (fetched in the background, never on the request path), else the origin of the request's own x402 resource.
|
|
174
|
+
const explicitOrigin = publicUrl ? new URL(publicUrl).origin : undefined;
|
|
175
|
+
let registeredOrigin;
|
|
176
|
+
const linksFor = (requestOrigin) => {
|
|
177
|
+
const base = explicitOrigin ?? registeredOrigin ?? requestOrigin;
|
|
178
|
+
const at = (path) => (base ? new URL(path, base).href : path);
|
|
179
|
+
return { rate: at(ratePath), form: at(basePath), summary: at(summaryPath) };
|
|
180
|
+
};
|
|
156
181
|
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
157
182
|
const fetchImpl = options.fetch ?? fetch;
|
|
158
|
-
const
|
|
183
|
+
const feedback = options.feedback !== false;
|
|
184
|
+
const describe = feedback && (options.describeChallenges ?? true);
|
|
159
185
|
const injectBody = options.injectBody ?? true;
|
|
160
186
|
const injectText = options.injectText ?? false;
|
|
161
187
|
const tone = options.tone ?? "soft";
|
|
162
|
-
const contextOption = options.agentContext
|
|
188
|
+
const contextOption = options.agentContext;
|
|
163
189
|
const collectContext = contextOption !== false;
|
|
164
190
|
const searchQuery = collectContext && (typeof contextOption !== "object" || contextOption.searchQuery !== false);
|
|
191
|
+
// Optional unless configured: true, or an object without required: false, keeps the strict (400) behavior.
|
|
192
|
+
const requiredContext = contextOption === true || (typeof contextOption === "object" && contextOption.required !== false);
|
|
165
193
|
const receipts = options.receiptExtension ?? true;
|
|
166
194
|
const rateHint = options.rateHint === false
|
|
167
195
|
? null
|
|
@@ -186,27 +214,48 @@ function enabledCore(options, configWarnings) {
|
|
|
186
214
|
lastLogged = message;
|
|
187
215
|
});
|
|
188
216
|
const reporter = new EventReporter(backend("events"), apiKey, fetchImpl, onError, options.flushIntervalMs ?? 2000);
|
|
189
|
-
const
|
|
190
|
-
// Idempotency marker: the rate
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
217
|
+
const sentenceTemplate = options.challengeSentence ?? ASK[tone].challengeSentence;
|
|
218
|
+
// Idempotency marker: the rate path, which every described challenge contains whatever origin its link uses.
|
|
219
|
+
const describedBy = (sentence, rate) => (sentence.includes(rate) ? ratePath : sentence);
|
|
220
|
+
const sentenceFor = (links) => feedback ? sentenceTemplate.replaceAll("{rate_url}", links.rate).replaceAll("{summary_url}", links.summary) : "";
|
|
221
|
+
const additionsCache = new Map();
|
|
222
|
+
function challengeAdditions(requestOrigin) {
|
|
223
|
+
const links = linksFor(requestOrigin);
|
|
224
|
+
const cached = additionsCache.get(links.rate);
|
|
225
|
+
if (cached)
|
|
226
|
+
return cached;
|
|
227
|
+
const sentence = sentenceFor(links);
|
|
228
|
+
const marker = describedBy(sentence, links.rate);
|
|
229
|
+
const additions = {
|
|
230
|
+
...(describe ? { sentence, marker, ...(marker === ratePath ? { shortSentence: ASK[tone].shortChallengeSentence.replaceAll("{rate_url}", links.rate) } : {}) } : {}),
|
|
231
|
+
...(!feedback || options.challengeExtension === false ? {} : { extension: feedbackExtension(links.rate, tone) }),
|
|
232
|
+
};
|
|
233
|
+
if (collectContext)
|
|
234
|
+
additions.contextExtension = { info: { agent_context: agentContextAsk({ searchQuery, required: requiredContext }), optional: !requiredContext } };
|
|
235
|
+
if (collectContext)
|
|
236
|
+
additions.bazaarContext = { searchQuery, required: requiredContext };
|
|
237
|
+
if (additionsCache.size >= 16)
|
|
238
|
+
additionsCache.clear(); // one entry per origin; bounded against spoofed Host headers
|
|
239
|
+
additionsCache.set(links.rate, additions);
|
|
240
|
+
return additions;
|
|
241
|
+
}
|
|
242
|
+
const touchChallenges = Boolean((describe && feedback) || (feedback && options.challengeExtension !== false) || collectContext);
|
|
197
243
|
// Set once a document has been enriched; lets body injection respect strict response schemas.
|
|
198
244
|
let allowInjection = null;
|
|
199
245
|
let lastWarnings = "";
|
|
200
246
|
function enrich(document) {
|
|
247
|
+
const links = linksFor();
|
|
248
|
+
const sentence = sentenceFor(links);
|
|
201
249
|
const result = enrichOpenApi(document, {
|
|
202
|
-
publicUrl,
|
|
250
|
+
publicUrl: explicitOrigin ?? registeredOrigin,
|
|
251
|
+
feedback,
|
|
203
252
|
basePath,
|
|
204
|
-
sentence
|
|
205
|
-
marker:
|
|
253
|
+
sentence,
|
|
254
|
+
marker: describedBy(sentence, links.rate),
|
|
206
255
|
isPaidOperation: openapi?.isPaidOperation,
|
|
207
256
|
describeOperations: openapi?.describeOperations,
|
|
208
257
|
hintField: Boolean(rateHint),
|
|
209
|
-
agentContext: collectContext ? { searchQuery } : undefined,
|
|
258
|
+
agentContext: collectContext ? { searchQuery, required: requiredContext } : undefined,
|
|
210
259
|
});
|
|
211
260
|
stats.openapi = result.report;
|
|
212
261
|
if (result.report.enriched)
|
|
@@ -221,30 +270,65 @@ function enabledCore(options, configWarnings) {
|
|
|
221
270
|
// With interception or an async provider, that starts once the spec has been served.
|
|
222
271
|
if (openapi && openapi.document !== undefined && typeof openapi.document !== "function")
|
|
223
272
|
enrich(openapi.document);
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
method: "
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
273
|
+
const formDoc = () => {
|
|
274
|
+
const links = linksFor();
|
|
275
|
+
return {
|
|
276
|
+
protocol: PROTOCOL,
|
|
277
|
+
cost: "free",
|
|
278
|
+
ratings_are_public_at: links.summary,
|
|
279
|
+
feedback_id: `Returned by paid responses in the ${FEEDBACK_FIELD} body object (feedback_id) and the Forge-Feedback-Id header.`,
|
|
280
|
+
quick: { method: "GET", url: `${links.rate}?feedback_id=FEEDBACK_ID&outcome=OUTCOME`, optional_param: "issue=ISSUE" },
|
|
281
|
+
detailed: {
|
|
282
|
+
method: "POST",
|
|
283
|
+
url: links.form,
|
|
284
|
+
content_type: "application/json",
|
|
285
|
+
body: { feedback_id: "FEEDBACK_ID", outcome: "OUTCOME", issue: "ISSUE (optional)", note: `NOTE (optional, max ${NOTE_MAX_LENGTH} chars, no user data)` },
|
|
286
|
+
},
|
|
287
|
+
fields: {
|
|
288
|
+
outcome: { required: true, values: OUTCOMES },
|
|
289
|
+
issue: { required: false, values: ISSUES },
|
|
290
|
+
note: { required: false, max_length: NOTE_MAX_LENGTH, post_only: true },
|
|
291
|
+
},
|
|
292
|
+
};
|
|
241
293
|
};
|
|
294
|
+
// The origin registered in Forge, fetched in the background on first use and refreshed every few hours.
|
|
295
|
+
// Requests never wait on it; until it arrives, links fall back to the request's own resource origin.
|
|
296
|
+
let originCheckedAt = -Infinity;
|
|
297
|
+
const ORIGIN_REFRESH_MS = 6 * 3600_000;
|
|
298
|
+
function refreshRegisteredOrigin() {
|
|
299
|
+
if (explicitOrigin || Date.now() - originCheckedAt < ORIGIN_REFRESH_MS)
|
|
300
|
+
return;
|
|
301
|
+
originCheckedAt = Date.now();
|
|
302
|
+
void (async () => {
|
|
303
|
+
try {
|
|
304
|
+
const response = await fetchImpl(backend("project"), {
|
|
305
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
306
|
+
signal: AbortSignal.timeout(5000),
|
|
307
|
+
});
|
|
308
|
+
if (!response.ok)
|
|
309
|
+
return;
|
|
310
|
+
const origin = (await response.json())?.publicOrigin;
|
|
311
|
+
if (typeof origin !== "string" || !origin.startsWith("https://"))
|
|
312
|
+
return;
|
|
313
|
+
const next = new URL(origin).origin;
|
|
314
|
+
if (next === registeredOrigin)
|
|
315
|
+
return;
|
|
316
|
+
registeredOrigin = next;
|
|
317
|
+
additionsCache.clear();
|
|
318
|
+
if (openapi && openapi.document !== undefined && typeof openapi.document !== "function")
|
|
319
|
+
enrich(openapi.document);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// Offline, older backend, or blocked: keep the fallbacks.
|
|
323
|
+
}
|
|
324
|
+
})();
|
|
325
|
+
}
|
|
242
326
|
const reply = (status, body, headers = FEEDBACK_HEADERS) => body === undefined ? { status, headers } : { status, headers, body };
|
|
243
327
|
const badRequest = (error) => reply(400, {
|
|
244
328
|
recorded: false,
|
|
245
329
|
error,
|
|
246
330
|
allowed: { outcome: Object.keys(OUTCOMES), issue: Object.keys(ISSUES) },
|
|
247
|
-
example: `${
|
|
331
|
+
example: `${linksFor().rate}?feedback_id=FEEDBACK_ID&outcome=fully`,
|
|
248
332
|
});
|
|
249
333
|
async function submit(request, input, via) {
|
|
250
334
|
const parsed = parseSubmission(input, { allowNote: via === "POST" });
|
|
@@ -258,7 +342,7 @@ function enabledCore(options, configWarnings) {
|
|
|
258
342
|
const upstream = await fetchImpl(backend("feedback"), {
|
|
259
343
|
method: "POST",
|
|
260
344
|
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
261
|
-
body: JSON.stringify({ ...parsed.value, via, user_agent: request.header("user-agent") ?? null }),
|
|
345
|
+
body: JSON.stringify({ ...parsed.value, via, user_agent: request.header("user-agent") ?? null, request_headers: captureClientHeaders((name) => request.header(name)) }),
|
|
262
346
|
signal: AbortSignal.timeout(3000),
|
|
263
347
|
});
|
|
264
348
|
const body = await upstream.json().catch(() => ({ recorded: false, error: "feedback_unavailable" }));
|
|
@@ -288,6 +372,7 @@ function enabledCore(options, configWarnings) {
|
|
|
288
372
|
return reply(summaryCache.status, summaryCache.body, { "Cache-Control": "public, max-age=30" });
|
|
289
373
|
}
|
|
290
374
|
async function route(request) {
|
|
375
|
+
refreshRegisteredOrigin();
|
|
291
376
|
try {
|
|
292
377
|
return await handleRoute(request);
|
|
293
378
|
}
|
|
@@ -299,9 +384,9 @@ function enabledCore(options, configWarnings) {
|
|
|
299
384
|
}
|
|
300
385
|
async function handleRoute(request) {
|
|
301
386
|
const { method, path } = request;
|
|
302
|
-
if (path === basePath) {
|
|
387
|
+
if (feedback && path === basePath) {
|
|
303
388
|
if (method === "GET" || method === "HEAD")
|
|
304
|
-
return reply(200,
|
|
389
|
+
return reply(200, formDoc());
|
|
305
390
|
if (method === "POST") {
|
|
306
391
|
let body;
|
|
307
392
|
try {
|
|
@@ -315,23 +400,29 @@ function enabledCore(options, configWarnings) {
|
|
|
315
400
|
return submit(request, body, "POST");
|
|
316
401
|
}
|
|
317
402
|
}
|
|
318
|
-
if (path === summaryPath && (method === "GET" || method === "HEAD"))
|
|
403
|
+
if (feedback && path === summaryPath && (method === "GET" || method === "HEAD"))
|
|
319
404
|
return summary();
|
|
320
|
-
if (path === ratePath) {
|
|
405
|
+
if (feedback && path === ratePath) {
|
|
321
406
|
// HEAD and other methods must never record a rating (link checkers send HEAD).
|
|
322
407
|
if (method !== "GET")
|
|
323
408
|
return reply(405, undefined, { ...FEEDBACK_HEADERS, Allow: "GET" });
|
|
324
409
|
const first = (v) => (Array.isArray(v) ? v[0] : v);
|
|
325
410
|
return submit(request, { feedback_id: first(request.query("feedback_id")), outcome: first(request.query("outcome")), issue: first(request.query("issue")) }, "GET");
|
|
326
411
|
}
|
|
327
|
-
if (openapi &&
|
|
328
|
-
const
|
|
329
|
-
|
|
412
|
+
if (openapi && method === "GET" && specPaths.has(path)) {
|
|
413
|
+
const requestHeaders = captureClientHeaders((name) => request.header(name));
|
|
414
|
+
reporter.push({ type: "discovery", route: `${method} ${path}`, ts: Date.now(), user_agent: requestHeaders?.["user-agent"], request_headers: requestHeaders });
|
|
415
|
+
if (openapi.document !== undefined) {
|
|
416
|
+
const source = typeof openapi.document === "function" ? await openapi.document() : openapi.document;
|
|
417
|
+
return reply(200, enrich(source).document, { "Cache-Control": "no-store" });
|
|
418
|
+
}
|
|
330
419
|
}
|
|
331
420
|
return null;
|
|
332
421
|
}
|
|
333
422
|
const passThrough = {
|
|
334
423
|
feedbackId: undefined,
|
|
424
|
+
contextRequired: false,
|
|
425
|
+
contextError: () => null,
|
|
335
426
|
json: (_status, body) => body,
|
|
336
427
|
text: (_status, _type, body) => body,
|
|
337
428
|
headers: () => ({}),
|
|
@@ -352,18 +443,26 @@ function enabledCore(options, configWarnings) {
|
|
|
352
443
|
const started = Date.now();
|
|
353
444
|
const route = `${request.method} ${request.path}`;
|
|
354
445
|
const userAgent = request.header("user-agent") ?? undefined;
|
|
446
|
+
const requestHeaders = captureClientHeaders((name) => request.header(name));
|
|
355
447
|
const paymentHeader = request.header("payment-signature") ?? request.header("x-payment"); // x402 v2 / v1
|
|
356
|
-
const feedbackId = paymentHeader ? mintFeedbackId(signingKey) : undefined;
|
|
448
|
+
const feedbackId = feedback && paymentHeader ? mintFeedbackId(signingKey) : undefined;
|
|
449
|
+
const interactionId = paymentHeader && !feedback ? randomUUID() : undefined;
|
|
357
450
|
if (feedbackId)
|
|
358
451
|
stats.minted++;
|
|
359
|
-
|
|
452
|
+
refreshRegisteredOrigin();
|
|
453
|
+
// Links in this paid response use the origin the payment was made for when none is configured or registered.
|
|
454
|
+
const callLinks = feedbackId ? linksFor(paymentOrigin(paymentHeader)) : undefined;
|
|
455
|
+
const feedbackUrl = callLinks ? `${callLinks.rate}?feedback_id=${feedbackId}&outcome=` : "";
|
|
360
456
|
let bodyChallenge = false;
|
|
361
457
|
let headerChallenge = false;
|
|
362
458
|
let context;
|
|
459
|
+
let rawContext = {};
|
|
363
460
|
let receiptFacts = {};
|
|
364
461
|
const remember = (raw) => {
|
|
365
462
|
if (!collectContext)
|
|
366
463
|
return;
|
|
464
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw))
|
|
465
|
+
rawContext = { ...rawContext, ...raw };
|
|
367
466
|
const parsed = parseAgentContext(raw, { searchQuery });
|
|
368
467
|
if (parsed)
|
|
369
468
|
context = { ...context, ...parsed };
|
|
@@ -371,12 +470,24 @@ function enabledCore(options, configWarnings) {
|
|
|
371
470
|
const ok = (status) => status >= 200 && status < 300;
|
|
372
471
|
const handle = {
|
|
373
472
|
feedbackId,
|
|
473
|
+
contextRequired: requiredContext && !!paymentHeader,
|
|
474
|
+
contextError() {
|
|
475
|
+
if (!handle.contextRequired)
|
|
476
|
+
return null;
|
|
477
|
+
const issues = contextIssues(rawContext, { searchQuery });
|
|
478
|
+
return issues.length ? {
|
|
479
|
+
status: 400, headers: {}, body: {
|
|
480
|
+
error: "agent_context_required", issues,
|
|
481
|
+
message: agentContextAsk({ searchQuery, required: true }),
|
|
482
|
+
},
|
|
483
|
+
} : null;
|
|
484
|
+
},
|
|
374
485
|
json(status, body) {
|
|
375
486
|
try {
|
|
376
487
|
if (status === 402) {
|
|
377
488
|
bodyChallenge = !!body && typeof body === "object" && "x402Version" in body;
|
|
378
489
|
// v1 challenges (and v2 challenges echoed in the body) live in the JSON body.
|
|
379
|
-
const described = touchChallenges ? describeChallengeBody(body, challengeAdditions) : undefined;
|
|
490
|
+
const described = touchChallenges ? describeChallengeBody(body, challengeAdditions(challengeOrigin(body))) : undefined;
|
|
380
491
|
if (described) {
|
|
381
492
|
stats.challengesDescribed++;
|
|
382
493
|
return described;
|
|
@@ -388,15 +499,16 @@ function enabledCore(options, configWarnings) {
|
|
|
388
499
|
body !== null &&
|
|
389
500
|
typeof body === "object" &&
|
|
390
501
|
Object.getPrototypeOf(body) === Object.prototype &&
|
|
391
|
-
!(
|
|
392
|
-
!("feedback_url" in body) &&
|
|
393
|
-
!(rateHint && "rate_this_call" in body) &&
|
|
502
|
+
!(FEEDBACK_FIELD in body) &&
|
|
394
503
|
(allowInjection?.(request.method, request.path, status) ?? true)) {
|
|
504
|
+
// One namespaced object, so the merchant's own fields stay recognizably theirs.
|
|
395
505
|
return {
|
|
396
506
|
...body,
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
507
|
+
[FEEDBACK_FIELD]: {
|
|
508
|
+
feedback_id: feedbackId,
|
|
509
|
+
feedback_url: feedbackUrl,
|
|
510
|
+
...(rateHint ? { rate_this_call: rateHint.replaceAll("{feedback_url}", feedbackUrl).replaceAll("{summary_url}", callLinks.summary) } : {}),
|
|
511
|
+
},
|
|
400
512
|
};
|
|
401
513
|
}
|
|
402
514
|
}
|
|
@@ -418,17 +530,17 @@ function enabledCore(options, configWarnings) {
|
|
|
418
530
|
if (status === 402 && paymentRequired)
|
|
419
531
|
headerChallenge = true;
|
|
420
532
|
if (status === 402 && touchChallenges && paymentRequired) {
|
|
421
|
-
const next = describeChallenge(paymentRequired, challengeAdditions);
|
|
533
|
+
const next = describeChallenge(paymentRequired, challengeAdditions(challengeOrigin(paymentRequired)));
|
|
422
534
|
if (next) {
|
|
423
535
|
set["PAYMENT-REQUIRED"] = next;
|
|
424
536
|
stats.challengesDescribed++;
|
|
425
537
|
}
|
|
426
538
|
}
|
|
539
|
+
if (paymentHeader && ok(status) && paymentResponse)
|
|
540
|
+
receiptFacts = readReceipt(paymentResponse);
|
|
427
541
|
if (feedbackId && ok(status)) {
|
|
428
542
|
set["Forge-Feedback-Id"] = feedbackId;
|
|
429
|
-
|
|
430
|
-
receiptFacts = readReceipt(paymentResponse);
|
|
431
|
-
const receipt = receipts && paymentResponse ? describeReceipt(paymentResponse, receiptExtension(rateUrl, feedbackId, tone)) : undefined;
|
|
543
|
+
const receipt = receipts && paymentResponse ? describeReceipt(paymentResponse, receiptExtension(callLinks.rate, feedbackId, tone)) : undefined;
|
|
432
544
|
if (receipt)
|
|
433
545
|
set["PAYMENT-RESPONSE"] = receipt;
|
|
434
546
|
}
|
|
@@ -475,19 +587,20 @@ function enabledCore(options, configWarnings) {
|
|
|
475
587
|
const ts = Date.now();
|
|
476
588
|
if (status === 402 && (hadChallengeHeader || headerChallenge || bodyChallenge)) {
|
|
477
589
|
// v2 carries the challenge in a header; v1 in the body. Both count as a challenge.
|
|
478
|
-
reporter.push({ type: "challenge", route, ts, user_agent: userAgent, ...(context ? { agent_context: context } : {}) });
|
|
590
|
+
reporter.push({ type: "challenge", route, ts, user_agent: userAgent, request_headers: requestHeaders, ...(context ? { agent_context: context } : {}) });
|
|
479
591
|
}
|
|
480
|
-
if (feedbackId) {
|
|
592
|
+
if (feedbackId || interactionId) {
|
|
481
593
|
const facts = { ...readPaymentHeader(paymentHeader), ...receiptFacts };
|
|
482
594
|
reporter.push({
|
|
483
595
|
type: "interaction",
|
|
484
|
-
feedback_id: feedbackId,
|
|
596
|
+
...(feedbackId ? { feedback_id: feedbackId } : { interaction_id: interactionId }),
|
|
485
597
|
route,
|
|
486
598
|
status,
|
|
487
599
|
latency_ms: ts - started,
|
|
488
600
|
// Only report the payer once the call succeeded (i.e. settlement went through).
|
|
489
601
|
...(ok(status) ? facts : { network: facts.network, amount: facts.amount }),
|
|
490
602
|
user_agent: userAgent,
|
|
603
|
+
request_headers: requestHeaders,
|
|
491
604
|
...(context ? { agent_context: context } : {}),
|
|
492
605
|
ts,
|
|
493
606
|
});
|
|
@@ -497,7 +610,9 @@ function enabledCore(options, configWarnings) {
|
|
|
497
610
|
}
|
|
498
611
|
return {
|
|
499
612
|
enabled: true,
|
|
500
|
-
challengeSentence
|
|
613
|
+
get challengeSentence() {
|
|
614
|
+
return sentenceFor(linksFor());
|
|
615
|
+
},
|
|
501
616
|
route,
|
|
502
617
|
isSpecRequest: (method, path) => Boolean(openapi) && method === "GET" && specPaths.has(path),
|
|
503
618
|
enrichOpenApi: enrich,
|
package/dist/express.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// Express adapter (4.21+ and 5): wires the framework-free core into req/res.
|
|
2
|
+
import express from "express";
|
|
1
3
|
import { CONTEXT_QUERY } from "./context.js";
|
|
2
4
|
import { BODY_LIMIT, SPEC_LIMIT, createForgeCore } from "./core.js";
|
|
3
5
|
/** Never throws (unless `strict`): with invalid options it warns and returns a middleware that only calls next(). */
|
|
@@ -124,7 +126,7 @@ export function createForge(options) {
|
|
|
124
126
|
},
|
|
125
127
|
});
|
|
126
128
|
}
|
|
127
|
-
function observe(req, res) {
|
|
129
|
+
async function observe(req, res) {
|
|
128
130
|
const call = core.call({ method: req.method, path: req.originalUrl.split("?")[0], header: (name) => req.get(name) });
|
|
129
131
|
try {
|
|
130
132
|
takeAgentContext(req, call);
|
|
@@ -132,6 +134,24 @@ export function createForge(options) {
|
|
|
132
134
|
catch (error) {
|
|
133
135
|
core.onError(error);
|
|
134
136
|
}
|
|
137
|
+
if (call.contextRequired) {
|
|
138
|
+
// Required context: parse before payment middleware even when the merchant mounts express.json later.
|
|
139
|
+
// The body setter above captures context and leaves only merchant fields in req.body.
|
|
140
|
+
const parseError = await new Promise((resolve) => {
|
|
141
|
+
express.json({ limit: "1mb", type: ["application/json", "application/*+json"] })(req, res, resolve);
|
|
142
|
+
});
|
|
143
|
+
if (parseError) {
|
|
144
|
+
call.finish(400);
|
|
145
|
+
send(res, { status: 400, headers: {}, body: { error: "agent_context_invalid", message: "Send valid JSON up to 1 MiB with agent_context, or use agent_type and agent_search_query query parameters with a non-JSON body." } });
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
const invalid = call.contextError();
|
|
149
|
+
if (invalid) {
|
|
150
|
+
call.finish(invalid.status);
|
|
151
|
+
send(res, invalid);
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
135
155
|
const originalJson = res.json.bind(res);
|
|
136
156
|
res.json = ((body) => originalJson(call.json(res.statusCode, body)));
|
|
137
157
|
if (call.feedbackId) {
|
|
@@ -173,6 +193,7 @@ export function createForge(options) {
|
|
|
173
193
|
return originalWriteHead.call(this, statusCode, ...rest);
|
|
174
194
|
};
|
|
175
195
|
res.on("finish", () => call.finish(res.statusCode, Boolean(res.getHeader("payment-required"))));
|
|
196
|
+
return true;
|
|
176
197
|
}
|
|
177
198
|
return {
|
|
178
199
|
enabled: core.enabled,
|
|
@@ -181,8 +202,18 @@ export function createForge(options) {
|
|
|
181
202
|
diagnostics: core.diagnostics,
|
|
182
203
|
shutdown: core.shutdown,
|
|
183
204
|
middleware() {
|
|
184
|
-
if (!core.enabled)
|
|
185
|
-
|
|
205
|
+
if (!core.enabled) {
|
|
206
|
+
// Disabled by invalid options: only remove agent context, so strict validators never see it.
|
|
207
|
+
return (req, _res, next) => {
|
|
208
|
+
try {
|
|
209
|
+
takeAgentContext(req, core.call({ method: req.method, path: req.path, header: () => undefined }));
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// never break the business request
|
|
213
|
+
}
|
|
214
|
+
next();
|
|
215
|
+
};
|
|
216
|
+
}
|
|
186
217
|
return (req, res, next) => {
|
|
187
218
|
core
|
|
188
219
|
.route({
|
|
@@ -192,14 +223,14 @@ export function createForge(options) {
|
|
|
192
223
|
query: (name) => req.query[name],
|
|
193
224
|
json: () => readJsonBody(req),
|
|
194
225
|
})
|
|
195
|
-
.then((response) => {
|
|
226
|
+
.then(async (response) => {
|
|
196
227
|
if (response)
|
|
197
228
|
return send(res, response);
|
|
198
229
|
try {
|
|
199
230
|
if (core.isSpecRequest(req.method, req.path))
|
|
200
231
|
captureSpec(req, res);
|
|
201
|
-
else
|
|
202
|
-
|
|
232
|
+
else if (!await observe(req, res))
|
|
233
|
+
return;
|
|
203
234
|
}
|
|
204
235
|
catch (error) {
|
|
205
236
|
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
|
|
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. */
|