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