@forgeintel/sdk 0.5.0-beta.1 → 0.5.0-beta.11
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 +29 -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/context.d.ts +33 -16
- package/dist/context.js +62 -27
- package/dist/core.d.ts +16 -7
- package/dist/core.js +154 -55
- package/dist/express.js +25 -4
- package/dist/fetch.d.ts +3 -1
- package/dist/fetch.js +74 -6
- package/dist/hono.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/next.js +4 -0
- package/dist/openapi.d.ts +6 -5
- package/dist/openapi.js +38 -21
- package/dist/x402.d.ts +25 -7
- package/dist/x402.js +64 -12
- package/package.json +3 -3
package/dist/core.js
CHANGED
|
@@ -4,16 +4,17 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
import { DEFAULT_TTL_MS, deriveSigningKey, mintFeedbackId, verifyFeedbackId } from "./id.js";
|
|
5
5
|
import { createOperationIndex, enrichOpenApi } from "./openapi.js";
|
|
6
6
|
import { ASK, TONES, checkAskText } from "./ask.js";
|
|
7
|
-
import { agentContextAsk, parseAgentContext, takeFromBody, takeFromUrl } from "./context.js";
|
|
7
|
+
import { agentContextAsk, contextIssues, parseAgentContext, takeFromBody, takeFromUrl } from "./context.js";
|
|
8
8
|
import { EventReporter } from "./reporter.js";
|
|
9
9
|
import { captureClientHeaders } from "./client-signals.js";
|
|
10
10
|
import { ISSUES, NOTE_MAX_LENGTH, OUTCOMES, PROTOCOL, parseSubmission } from "./values.js";
|
|
11
|
-
import { describeChallenge, describeChallengeBody, describeReceipt, feedbackExtension, readPaymentHeader, readReceipt, receiptExtension } from "./x402.js";
|
|
11
|
+
import { FEEDBACK_FIELD, challengeOrigin, describeChallenge, describeChallengeBody, describeReceipt, feedbackExtension, paymentOrigin, readPaymentHeader, readReceipt, receiptExtension } from "./x402.js";
|
|
12
12
|
export const FEEDBACK_HEADERS = { "Cache-Control": "no-store", "X-Robots-Tag": "noindex" };
|
|
13
13
|
/** Max JSON body for POST {basePath}. */
|
|
14
14
|
export const BODY_LIMIT = 8 * 1024;
|
|
15
15
|
/** Max OpenAPI document an adapter should buffer for enrichment. */
|
|
16
16
|
export const SPEC_LIMIT = 10 * 1024 * 1024;
|
|
17
|
+
export const DEFAULT_BACKEND_URL = "https://app-api.forgeintel.co/api/sdk/v2";
|
|
17
18
|
/**
|
|
18
19
|
* Check options without throwing. Invalid required options are errors (Forge runs disabled);
|
|
19
20
|
* invalid optional values are warnings and fall back to their defaults.
|
|
@@ -36,10 +37,12 @@ export function checkOptions(input) {
|
|
|
36
37
|
};
|
|
37
38
|
if (typeof raw.apiKey !== "string" || !raw.apiKey.trim())
|
|
38
39
|
errors.push("apiKey is missing (set it to your merchant key, ffk_…)");
|
|
39
|
-
if (
|
|
40
|
+
if (raw.backendUrl === undefined)
|
|
41
|
+
options.backendUrl = DEFAULT_BACKEND_URL;
|
|
42
|
+
else if (!absoluteUrl(raw.backendUrl))
|
|
40
43
|
errors.push(`backendUrl must be an absolute http(s) URL (got ${JSON.stringify(raw.backendUrl ?? null)})`);
|
|
41
|
-
if (!absoluteUrl(raw.publicUrl))
|
|
42
|
-
errors.push(`publicUrl must be an absolute http(s) URL
|
|
44
|
+
if (raw.publicUrl !== undefined && !absoluteUrl(raw.publicUrl))
|
|
45
|
+
errors.push(`publicUrl must be an absolute http(s) URL (got ${JSON.stringify(raw.publicUrl ?? null)})`);
|
|
43
46
|
const fallback = (key, ok, expected) => {
|
|
44
47
|
if (raw[key] === undefined || ok)
|
|
45
48
|
return;
|
|
@@ -61,7 +64,15 @@ export function checkOptions(input) {
|
|
|
61
64
|
fallback("tone", TONES.includes(raw.tone), `must be one of ${TONES.map((t) => `"${t}"`).join(", ")}`);
|
|
62
65
|
fallback("challengeSentence", typeof raw.challengeSentence === "string" && raw.challengeSentence.trim() !== "", "must be a non-empty string");
|
|
63
66
|
fallback("rateHint", boolean(raw.rateHint) || typeof raw.rateHint === "string", "must be a boolean or a string");
|
|
64
|
-
fallback("agentContext", boolean(raw.agentContext) || (!!raw.agentContext && typeof raw.agentContext === "object" && !Array.isArray(raw.agentContext)), "must be true, false or { searchQuery }");
|
|
67
|
+
fallback("agentContext", boolean(raw.agentContext) || (!!raw.agentContext && typeof raw.agentContext === "object" && !Array.isArray(raw.agentContext)), "must be true, false or { searchQuery, required }");
|
|
68
|
+
if (raw.agentContext && typeof raw.agentContext === "object" && !Array.isArray(raw.agentContext)) {
|
|
69
|
+
const context = raw.agentContext;
|
|
70
|
+
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
|
+
}
|
|
65
76
|
for (const key of ["feedback", "describeChallenges", "challengeExtension", "receiptExtension", "injectBody", "injectText", "strict"])
|
|
66
77
|
fallback(key, boolean(raw[key]), "must be true or false");
|
|
67
78
|
// The lines no wording may cross, whatever the merchant configures (see ask.ts).
|
|
@@ -97,6 +108,8 @@ export function checkOptions(input) {
|
|
|
97
108
|
function disabledCore(errors, warnings) {
|
|
98
109
|
const passThrough = {
|
|
99
110
|
feedbackId: undefined,
|
|
111
|
+
contextRequired: false,
|
|
112
|
+
contextError: () => null,
|
|
100
113
|
json: (_status, body) => body,
|
|
101
114
|
text: (_status, _type, body) => body,
|
|
102
115
|
headers: () => ({}),
|
|
@@ -144,17 +157,24 @@ export function createForgeCore(input) {
|
|
|
144
157
|
}
|
|
145
158
|
}
|
|
146
159
|
function enabledCore(options, configWarnings) {
|
|
147
|
-
const { apiKey,
|
|
160
|
+
const { apiKey, publicUrl } = options;
|
|
161
|
+
const backendUrl = options.backendUrl ?? DEFAULT_BACKEND_URL;
|
|
148
162
|
// Feedback IDs are signed with a key derived from the API key, never with the API key itself.
|
|
149
163
|
const signingKey = deriveSigningKey(apiKey);
|
|
150
164
|
const backendPath = new URL(backendUrl).pathname.replace(/\/+$/, "") || "/v1";
|
|
151
165
|
const backend = (name) => new URL(`${backendPath}/${name}`, backendUrl).href;
|
|
152
166
|
const basePath = (options.basePath ?? "/feedback").replace(/\/+$/, "");
|
|
153
167
|
const ratePath = `${basePath}/rate`;
|
|
154
|
-
const rateUrl = new URL(ratePath, publicUrl).href;
|
|
155
|
-
const formUrl = new URL(basePath, publicUrl).href;
|
|
156
168
|
const summaryPath = `${basePath}/summary`;
|
|
157
|
-
|
|
169
|
+
// Rating links are absolute whenever an origin is known: publicUrl, else the origin registered in Forge
|
|
170
|
+
// (fetched in the background, never on the request path), else the origin of the request's own x402 resource.
|
|
171
|
+
const explicitOrigin = publicUrl ? new URL(publicUrl).origin : undefined;
|
|
172
|
+
let registeredOrigin;
|
|
173
|
+
const linksFor = (requestOrigin) => {
|
|
174
|
+
const base = explicitOrigin ?? registeredOrigin ?? requestOrigin;
|
|
175
|
+
const at = (path) => (base ? new URL(path, base).href : path);
|
|
176
|
+
return { rate: at(ratePath), form: at(basePath), summary: at(summaryPath) };
|
|
177
|
+
};
|
|
158
178
|
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
159
179
|
const fetchImpl = options.fetch ?? fetch;
|
|
160
180
|
const feedback = options.feedback !== false;
|
|
@@ -162,9 +182,11 @@ function enabledCore(options, configWarnings) {
|
|
|
162
182
|
const injectBody = options.injectBody ?? true;
|
|
163
183
|
const injectText = options.injectText ?? false;
|
|
164
184
|
const tone = options.tone ?? "soft";
|
|
165
|
-
const contextOption = options.agentContext
|
|
185
|
+
const contextOption = options.agentContext;
|
|
166
186
|
const collectContext = contextOption !== false;
|
|
167
187
|
const searchQuery = collectContext && (typeof contextOption !== "object" || contextOption.searchQuery !== false);
|
|
188
|
+
// Optional unless configured: true, or an object without required: false, keeps the strict (400) behavior.
|
|
189
|
+
const requiredContext = contextOption === true || (typeof contextOption === "object" && contextOption.required !== false);
|
|
168
190
|
const receipts = options.receiptExtension ?? true;
|
|
169
191
|
const rateHint = options.rateHint === false
|
|
170
192
|
? null
|
|
@@ -189,30 +211,48 @@ function enabledCore(options, configWarnings) {
|
|
|
189
211
|
lastLogged = message;
|
|
190
212
|
});
|
|
191
213
|
const reporter = new EventReporter(backend("events"), apiKey, fetchImpl, onError, options.flushIntervalMs ?? 2000);
|
|
192
|
-
const
|
|
193
|
-
// Idempotency marker: the rate
|
|
194
|
-
const
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
214
|
+
const sentenceTemplate = options.challengeSentence ?? ASK[tone].challengeSentence;
|
|
215
|
+
// Idempotency marker: the rate path, which every described challenge contains whatever origin its link uses.
|
|
216
|
+
const describedBy = (sentence, rate) => (sentence.includes(rate) ? ratePath : sentence);
|
|
217
|
+
const sentenceFor = (links) => feedback ? sentenceTemplate.replaceAll("{rate_url}", links.rate).replaceAll("{summary_url}", links.summary) : "";
|
|
218
|
+
const additionsCache = new Map();
|
|
219
|
+
function challengeAdditions(requestOrigin) {
|
|
220
|
+
const links = linksFor(requestOrigin);
|
|
221
|
+
const cached = additionsCache.get(links.rate);
|
|
222
|
+
if (cached)
|
|
223
|
+
return cached;
|
|
224
|
+
const sentence = sentenceFor(links);
|
|
225
|
+
const marker = describedBy(sentence, links.rate);
|
|
226
|
+
const additions = {
|
|
227
|
+
...(describe ? { sentence, marker, ...(marker === ratePath ? { shortSentence: ASK[tone].shortChallengeSentence.replaceAll("{rate_url}", links.rate) } : {}) } : {}),
|
|
228
|
+
...(!feedback || options.challengeExtension === false ? {} : { extension: feedbackExtension(links.rate, tone) }),
|
|
229
|
+
};
|
|
230
|
+
if (collectContext)
|
|
231
|
+
additions.contextExtension = { info: { agent_context: agentContextAsk({ searchQuery, required: requiredContext }), optional: !requiredContext } };
|
|
232
|
+
if (collectContext)
|
|
233
|
+
additions.bazaarContext = { searchQuery, required: requiredContext };
|
|
234
|
+
if (additionsCache.size >= 16)
|
|
235
|
+
additionsCache.clear(); // one entry per origin; bounded against spoofed Host headers
|
|
236
|
+
additionsCache.set(links.rate, additions);
|
|
237
|
+
return additions;
|
|
238
|
+
}
|
|
239
|
+
const touchChallenges = Boolean((describe && feedback) || (feedback && options.challengeExtension !== false) || collectContext);
|
|
202
240
|
// Set once a document has been enriched; lets body injection respect strict response schemas.
|
|
203
241
|
let allowInjection = null;
|
|
204
242
|
let lastWarnings = "";
|
|
205
243
|
function enrich(document) {
|
|
244
|
+
const links = linksFor();
|
|
245
|
+
const sentence = sentenceFor(links);
|
|
206
246
|
const result = enrichOpenApi(document, {
|
|
207
|
-
publicUrl,
|
|
247
|
+
publicUrl: explicitOrigin ?? registeredOrigin,
|
|
208
248
|
feedback,
|
|
209
249
|
basePath,
|
|
210
|
-
sentence
|
|
211
|
-
marker:
|
|
250
|
+
sentence,
|
|
251
|
+
marker: describedBy(sentence, links.rate),
|
|
212
252
|
isPaidOperation: openapi?.isPaidOperation,
|
|
213
253
|
describeOperations: openapi?.describeOperations,
|
|
214
254
|
hintField: Boolean(rateHint),
|
|
215
|
-
agentContext: collectContext ? { searchQuery } : undefined,
|
|
255
|
+
agentContext: collectContext ? { searchQuery, required: requiredContext } : undefined,
|
|
216
256
|
});
|
|
217
257
|
stats.openapi = result.report;
|
|
218
258
|
if (result.report.enriched)
|
|
@@ -227,30 +267,65 @@ function enabledCore(options, configWarnings) {
|
|
|
227
267
|
// With interception or an async provider, that starts once the spec has been served.
|
|
228
268
|
if (openapi && openapi.document !== undefined && typeof openapi.document !== "function")
|
|
229
269
|
enrich(openapi.document);
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
method: "
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
270
|
+
const formDoc = () => {
|
|
271
|
+
const links = linksFor();
|
|
272
|
+
return {
|
|
273
|
+
protocol: PROTOCOL,
|
|
274
|
+
cost: "free",
|
|
275
|
+
ratings_are_public_at: links.summary,
|
|
276
|
+
feedback_id: `Returned by paid responses in the ${FEEDBACK_FIELD} body object (feedback_id) and the Forge-Feedback-Id header.`,
|
|
277
|
+
quick: { method: "GET", url: `${links.rate}?feedback_id=FEEDBACK_ID&outcome=OUTCOME`, optional_param: "issue=ISSUE" },
|
|
278
|
+
detailed: {
|
|
279
|
+
method: "POST",
|
|
280
|
+
url: links.form,
|
|
281
|
+
content_type: "application/json",
|
|
282
|
+
body: { feedback_id: "FEEDBACK_ID", outcome: "OUTCOME", issue: "ISSUE (optional)", note: `NOTE (optional, max ${NOTE_MAX_LENGTH} chars, no user data)` },
|
|
283
|
+
},
|
|
284
|
+
fields: {
|
|
285
|
+
outcome: { required: true, values: OUTCOMES },
|
|
286
|
+
issue: { required: false, values: ISSUES },
|
|
287
|
+
note: { required: false, max_length: NOTE_MAX_LENGTH, post_only: true },
|
|
288
|
+
},
|
|
289
|
+
};
|
|
247
290
|
};
|
|
291
|
+
// The origin registered in Forge, fetched in the background on first use and refreshed every few hours.
|
|
292
|
+
// Requests never wait on it; until it arrives, links fall back to the request's own resource origin.
|
|
293
|
+
let originCheckedAt = -Infinity;
|
|
294
|
+
const ORIGIN_REFRESH_MS = 6 * 3600_000;
|
|
295
|
+
function refreshRegisteredOrigin() {
|
|
296
|
+
if (explicitOrigin || Date.now() - originCheckedAt < ORIGIN_REFRESH_MS)
|
|
297
|
+
return;
|
|
298
|
+
originCheckedAt = Date.now();
|
|
299
|
+
void (async () => {
|
|
300
|
+
try {
|
|
301
|
+
const response = await fetchImpl(backend("project"), {
|
|
302
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
303
|
+
signal: AbortSignal.timeout(5000),
|
|
304
|
+
});
|
|
305
|
+
if (!response.ok)
|
|
306
|
+
return;
|
|
307
|
+
const origin = (await response.json())?.publicOrigin;
|
|
308
|
+
if (typeof origin !== "string" || !origin.startsWith("https://"))
|
|
309
|
+
return;
|
|
310
|
+
const next = new URL(origin).origin;
|
|
311
|
+
if (next === registeredOrigin)
|
|
312
|
+
return;
|
|
313
|
+
registeredOrigin = next;
|
|
314
|
+
additionsCache.clear();
|
|
315
|
+
if (openapi && openapi.document !== undefined && typeof openapi.document !== "function")
|
|
316
|
+
enrich(openapi.document);
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
// Offline, older backend, or blocked: keep the fallbacks.
|
|
320
|
+
}
|
|
321
|
+
})();
|
|
322
|
+
}
|
|
248
323
|
const reply = (status, body, headers = FEEDBACK_HEADERS) => body === undefined ? { status, headers } : { status, headers, body };
|
|
249
324
|
const badRequest = (error) => reply(400, {
|
|
250
325
|
recorded: false,
|
|
251
326
|
error,
|
|
252
327
|
allowed: { outcome: Object.keys(OUTCOMES), issue: Object.keys(ISSUES) },
|
|
253
|
-
example: `${
|
|
328
|
+
example: `${linksFor().rate}?feedback_id=FEEDBACK_ID&outcome=fully`,
|
|
254
329
|
});
|
|
255
330
|
async function submit(request, input, via) {
|
|
256
331
|
const parsed = parseSubmission(input, { allowNote: via === "POST" });
|
|
@@ -294,6 +369,7 @@ function enabledCore(options, configWarnings) {
|
|
|
294
369
|
return reply(summaryCache.status, summaryCache.body, { "Cache-Control": "public, max-age=30" });
|
|
295
370
|
}
|
|
296
371
|
async function route(request) {
|
|
372
|
+
refreshRegisteredOrigin();
|
|
297
373
|
try {
|
|
298
374
|
return await handleRoute(request);
|
|
299
375
|
}
|
|
@@ -307,7 +383,7 @@ function enabledCore(options, configWarnings) {
|
|
|
307
383
|
const { method, path } = request;
|
|
308
384
|
if (feedback && path === basePath) {
|
|
309
385
|
if (method === "GET" || method === "HEAD")
|
|
310
|
-
return reply(200,
|
|
386
|
+
return reply(200, formDoc());
|
|
311
387
|
if (method === "POST") {
|
|
312
388
|
let body;
|
|
313
389
|
try {
|
|
@@ -342,6 +418,8 @@ function enabledCore(options, configWarnings) {
|
|
|
342
418
|
}
|
|
343
419
|
const passThrough = {
|
|
344
420
|
feedbackId: undefined,
|
|
421
|
+
contextRequired: false,
|
|
422
|
+
contextError: () => null,
|
|
345
423
|
json: (_status, body) => body,
|
|
346
424
|
text: (_status, _type, body) => body,
|
|
347
425
|
headers: () => ({}),
|
|
@@ -368,14 +446,20 @@ function enabledCore(options, configWarnings) {
|
|
|
368
446
|
const interactionId = paymentHeader && !feedback ? randomUUID() : undefined;
|
|
369
447
|
if (feedbackId)
|
|
370
448
|
stats.minted++;
|
|
371
|
-
|
|
449
|
+
refreshRegisteredOrigin();
|
|
450
|
+
// Links in this paid response use the origin the payment was made for when none is configured or registered.
|
|
451
|
+
const callLinks = feedbackId ? linksFor(paymentOrigin(paymentHeader)) : undefined;
|
|
452
|
+
const feedbackUrl = callLinks ? `${callLinks.rate}?feedback_id=${feedbackId}&outcome=` : "";
|
|
372
453
|
let bodyChallenge = false;
|
|
373
454
|
let headerChallenge = false;
|
|
374
455
|
let context;
|
|
456
|
+
let rawContext = {};
|
|
375
457
|
let receiptFacts = {};
|
|
376
458
|
const remember = (raw) => {
|
|
377
459
|
if (!collectContext)
|
|
378
460
|
return;
|
|
461
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw))
|
|
462
|
+
rawContext = { ...rawContext, ...raw };
|
|
379
463
|
const parsed = parseAgentContext(raw, { searchQuery });
|
|
380
464
|
if (parsed)
|
|
381
465
|
context = { ...context, ...parsed };
|
|
@@ -383,12 +467,24 @@ function enabledCore(options, configWarnings) {
|
|
|
383
467
|
const ok = (status) => status >= 200 && status < 300;
|
|
384
468
|
const handle = {
|
|
385
469
|
feedbackId,
|
|
470
|
+
contextRequired: requiredContext && !!paymentHeader,
|
|
471
|
+
contextError() {
|
|
472
|
+
if (!handle.contextRequired)
|
|
473
|
+
return null;
|
|
474
|
+
const issues = contextIssues(rawContext, { searchQuery });
|
|
475
|
+
return issues.length ? {
|
|
476
|
+
status: 400, headers: {}, body: {
|
|
477
|
+
error: "agent_context_required", issues,
|
|
478
|
+
message: agentContextAsk({ searchQuery, required: true }),
|
|
479
|
+
},
|
|
480
|
+
} : null;
|
|
481
|
+
},
|
|
386
482
|
json(status, body) {
|
|
387
483
|
try {
|
|
388
484
|
if (status === 402) {
|
|
389
485
|
bodyChallenge = !!body && typeof body === "object" && "x402Version" in body;
|
|
390
486
|
// v1 challenges (and v2 challenges echoed in the body) live in the JSON body.
|
|
391
|
-
const described = touchChallenges ? describeChallengeBody(body, challengeAdditions) : undefined;
|
|
487
|
+
const described = touchChallenges ? describeChallengeBody(body, challengeAdditions(challengeOrigin(body))) : undefined;
|
|
392
488
|
if (described) {
|
|
393
489
|
stats.challengesDescribed++;
|
|
394
490
|
return described;
|
|
@@ -400,15 +496,16 @@ function enabledCore(options, configWarnings) {
|
|
|
400
496
|
body !== null &&
|
|
401
497
|
typeof body === "object" &&
|
|
402
498
|
Object.getPrototypeOf(body) === Object.prototype &&
|
|
403
|
-
!(
|
|
404
|
-
!("feedback_url" in body) &&
|
|
405
|
-
!(rateHint && "rate_this_call" in body) &&
|
|
499
|
+
!(FEEDBACK_FIELD in body) &&
|
|
406
500
|
(allowInjection?.(request.method, request.path, status) ?? true)) {
|
|
501
|
+
// One namespaced object, so the merchant's own fields stay recognizably theirs.
|
|
407
502
|
return {
|
|
408
503
|
...body,
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
504
|
+
[FEEDBACK_FIELD]: {
|
|
505
|
+
feedback_id: feedbackId,
|
|
506
|
+
feedback_url: feedbackUrl,
|
|
507
|
+
...(rateHint ? { rate_this_call: rateHint.replaceAll("{feedback_url}", feedbackUrl).replaceAll("{summary_url}", callLinks.summary) } : {}),
|
|
508
|
+
},
|
|
412
509
|
};
|
|
413
510
|
}
|
|
414
511
|
}
|
|
@@ -430,7 +527,7 @@ function enabledCore(options, configWarnings) {
|
|
|
430
527
|
if (status === 402 && paymentRequired)
|
|
431
528
|
headerChallenge = true;
|
|
432
529
|
if (status === 402 && touchChallenges && paymentRequired) {
|
|
433
|
-
const next = describeChallenge(paymentRequired, challengeAdditions);
|
|
530
|
+
const next = describeChallenge(paymentRequired, challengeAdditions(challengeOrigin(paymentRequired)));
|
|
434
531
|
if (next) {
|
|
435
532
|
set["PAYMENT-REQUIRED"] = next;
|
|
436
533
|
stats.challengesDescribed++;
|
|
@@ -440,7 +537,7 @@ function enabledCore(options, configWarnings) {
|
|
|
440
537
|
receiptFacts = readReceipt(paymentResponse);
|
|
441
538
|
if (feedbackId && ok(status)) {
|
|
442
539
|
set["Forge-Feedback-Id"] = feedbackId;
|
|
443
|
-
const receipt = receipts && paymentResponse ? describeReceipt(paymentResponse, receiptExtension(
|
|
540
|
+
const receipt = receipts && paymentResponse ? describeReceipt(paymentResponse, receiptExtension(callLinks.rate, feedbackId, tone)) : undefined;
|
|
444
541
|
if (receipt)
|
|
445
542
|
set["PAYMENT-RESPONSE"] = receipt;
|
|
446
543
|
}
|
|
@@ -510,7 +607,9 @@ function enabledCore(options, configWarnings) {
|
|
|
510
607
|
}
|
|
511
608
|
return {
|
|
512
609
|
enabled: true,
|
|
513
|
-
challengeSentence
|
|
610
|
+
get challengeSentence() {
|
|
611
|
+
return sentenceFor(linksFor());
|
|
612
|
+
},
|
|
514
613
|
route,
|
|
515
614
|
isSpecRequest: (method, path) => Boolean(openapi) && method === "GET" && specPaths.has(path),
|
|
516
615
|
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,
|
|
@@ -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. */
|
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
|
-
/**
|
|
4
|
+
/** Buffer limit; required 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.
|
|
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,15 +129,26 @@ 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"))
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
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) || !request.headers.has("content-length"))) {
|
|
139
|
+
// Optional context, best effort: a body that is too large or not JSON goes to the handler untouched.
|
|
140
|
+
const parsed = await boundedJson(request).catch(() => undefined);
|
|
141
|
+
if (parsed !== undefined) {
|
|
84
142
|
const without = call.requestBody(parsed);
|
|
85
143
|
if (without !== parsed)
|
|
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
package/dist/index.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export { deriveSigningKey, mintFeedbackId, verifyFeedbackId, FEEDBACK_ID_PATTERN
|
|
|
8
8
|
export type { FeedbackIdCheck } from "./id.js";
|
|
9
9
|
export { OUTCOMES, ISSUES, NOTE_MAX_LENGTH, PROTOCOL, parseSubmission } from "./values.js";
|
|
10
10
|
export type { Outcome, Issue, Submission } from "./values.js";
|
|
11
|
-
export { describeChallenge, describeChallengeBody, describeReceipt, feedbackExtension, receiptExtension, FEEDBACK_EXTENSION, readPaymentHeader, readReceipt } from "./x402.js";
|
|
11
|
+
export { describeChallenge, describeChallengeBody, describeReceipt, feedbackExtension, receiptExtension, FEEDBACK_EXTENSION, FEEDBACK_FIELD, readPaymentHeader, readReceipt } from "./x402.js";
|
|
12
12
|
export { ASK, TONES, checkAskText } from "./ask.js";
|
|
13
13
|
export { AGENT_TYPES, CLIENTS, CONTEXT_FIELD, CONTEXT_QUERY, agentContextSchema, parseAgentContext } from "./context.js";
|
|
14
14
|
export type { AgentContext } from "./context.js";
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,6 @@ export { createForgeCore, checkOptions, BODY_LIMIT, SPEC_LIMIT } from "./core.js
|
|
|
3
3
|
export { enrichOpenApi, detectVersion } from "./openapi.js";
|
|
4
4
|
export { deriveSigningKey, mintFeedbackId, verifyFeedbackId, FEEDBACK_ID_PATTERN, DEFAULT_TTL_MS } from "./id.js";
|
|
5
5
|
export { OUTCOMES, ISSUES, NOTE_MAX_LENGTH, PROTOCOL, parseSubmission } from "./values.js";
|
|
6
|
-
export { describeChallenge, describeChallengeBody, describeReceipt, feedbackExtension, receiptExtension, FEEDBACK_EXTENSION, readPaymentHeader, readReceipt } from "./x402.js";
|
|
6
|
+
export { describeChallenge, describeChallengeBody, describeReceipt, feedbackExtension, receiptExtension, FEEDBACK_EXTENSION, FEEDBACK_FIELD, readPaymentHeader, readReceipt } from "./x402.js";
|
|
7
7
|
export { ASK, TONES, checkAskText } from "./ask.js";
|
|
8
8
|
export { AGENT_TYPES, CLIENTS, CONTEXT_FIELD, CONTEXT_QUERY, agentContextSchema, parseAgentContext } from "./context.js";
|
package/dist/next.js
CHANGED
|
@@ -17,9 +17,13 @@ export function createForge(options) {
|
|
|
17
17
|
diagnostics: forge.diagnostics,
|
|
18
18
|
shutdown: forge.shutdown,
|
|
19
19
|
handle: forge.handle,
|
|
20
|
+
validate: forge.validate,
|
|
20
21
|
withForge: (handler) => (request, context) => forge.handle(request, (forwarded) => handler(forwarded === request ? request : sameKind(request, forwarded), context)),
|
|
21
22
|
routes: { GET: own, POST: own, HEAD: own },
|
|
22
23
|
proxy: (proxy) => async (request) => {
|
|
24
|
+
const invalid = await forge.validate(request);
|
|
25
|
+
if (invalid)
|
|
26
|
+
return invalid;
|
|
23
27
|
const response = await proxy(request);
|
|
24
28
|
// Only the 402: the proxy passes paid requests on to the route, where withForge mints the feedback ID.
|
|
25
29
|
if (response.status !== 402 || !forge.enabled)
|