@forgeintel/sdk 0.5.0-beta.0 → 0.5.0-beta.10
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 +30 -13
- package/dist/ask.d.ts +3 -1
- package/dist/ask.js +4 -2
- package/dist/bazaar.d.ts +7 -0
- package/dist/bazaar.js +58 -0
- package/dist/context.d.ts +34 -17
- package/dist/context.js +67 -24
- package/dist/core.d.ts +14 -6
- package/dist/core.js +154 -54
- 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 +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 +39 -21
- package/dist/x402.d.ts +27 -9
- package/dist/x402.js +66 -14
- 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,17 @@ 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
|
+
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
|
+
}
|
|
65
78
|
for (const key of ["feedback", "describeChallenges", "challengeExtension", "receiptExtension", "injectBody", "injectText", "strict"])
|
|
66
79
|
fallback(key, boolean(raw[key]), "must be true or false");
|
|
67
80
|
// The lines no wording may cross, whatever the merchant configures (see ask.ts).
|
|
@@ -97,6 +110,8 @@ export function checkOptions(input) {
|
|
|
97
110
|
function disabledCore(errors, warnings) {
|
|
98
111
|
const passThrough = {
|
|
99
112
|
feedbackId: undefined,
|
|
113
|
+
contextRequired: false,
|
|
114
|
+
contextError: () => null,
|
|
100
115
|
json: (_status, body) => body,
|
|
101
116
|
text: (_status, _type, body) => body,
|
|
102
117
|
headers: () => ({}),
|
|
@@ -144,17 +159,24 @@ export function createForgeCore(input) {
|
|
|
144
159
|
}
|
|
145
160
|
}
|
|
146
161
|
function enabledCore(options, configWarnings) {
|
|
147
|
-
const { apiKey,
|
|
162
|
+
const { apiKey, publicUrl } = options;
|
|
163
|
+
const backendUrl = options.backendUrl ?? DEFAULT_BACKEND_URL;
|
|
148
164
|
// Feedback IDs are signed with a key derived from the API key, never with the API key itself.
|
|
149
165
|
const signingKey = deriveSigningKey(apiKey);
|
|
150
166
|
const backendPath = new URL(backendUrl).pathname.replace(/\/+$/, "") || "/v1";
|
|
151
167
|
const backend = (name) => new URL(`${backendPath}/${name}`, backendUrl).href;
|
|
152
168
|
const basePath = (options.basePath ?? "/feedback").replace(/\/+$/, "");
|
|
153
169
|
const ratePath = `${basePath}/rate`;
|
|
154
|
-
const rateUrl = new URL(ratePath, publicUrl).href;
|
|
155
|
-
const formUrl = new URL(basePath, publicUrl).href;
|
|
156
170
|
const summaryPath = `${basePath}/summary`;
|
|
157
|
-
|
|
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
|
+
};
|
|
158
180
|
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
159
181
|
const fetchImpl = options.fetch ?? fetch;
|
|
160
182
|
const feedback = options.feedback !== false;
|
|
@@ -165,6 +187,7 @@ function enabledCore(options, configWarnings) {
|
|
|
165
187
|
const contextOption = options.agentContext ?? true;
|
|
166
188
|
const collectContext = contextOption !== false;
|
|
167
189
|
const searchQuery = collectContext && (typeof contextOption !== "object" || contextOption.searchQuery !== false);
|
|
190
|
+
const requiredContext = collectContext;
|
|
168
191
|
const receipts = options.receiptExtension ?? true;
|
|
169
192
|
const rateHint = options.rateHint === false
|
|
170
193
|
? null
|
|
@@ -189,30 +212,48 @@ function enabledCore(options, configWarnings) {
|
|
|
189
212
|
lastLogged = message;
|
|
190
213
|
});
|
|
191
214
|
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
|
-
|
|
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);
|
|
202
241
|
// Set once a document has been enriched; lets body injection respect strict response schemas.
|
|
203
242
|
let allowInjection = null;
|
|
204
243
|
let lastWarnings = "";
|
|
205
244
|
function enrich(document) {
|
|
245
|
+
const links = linksFor();
|
|
246
|
+
const sentence = sentenceFor(links);
|
|
206
247
|
const result = enrichOpenApi(document, {
|
|
207
|
-
publicUrl,
|
|
248
|
+
publicUrl: explicitOrigin ?? registeredOrigin,
|
|
208
249
|
feedback,
|
|
209
250
|
basePath,
|
|
210
|
-
sentence
|
|
211
|
-
marker:
|
|
251
|
+
sentence,
|
|
252
|
+
marker: describedBy(sentence, links.rate),
|
|
212
253
|
isPaidOperation: openapi?.isPaidOperation,
|
|
213
254
|
describeOperations: openapi?.describeOperations,
|
|
214
255
|
hintField: Boolean(rateHint),
|
|
215
|
-
agentContext: collectContext ? { searchQuery } : undefined,
|
|
256
|
+
agentContext: collectContext ? { searchQuery, required: requiredContext } : undefined,
|
|
216
257
|
});
|
|
217
258
|
stats.openapi = result.report;
|
|
218
259
|
if (result.report.enriched)
|
|
@@ -227,30 +268,65 @@ function enabledCore(options, configWarnings) {
|
|
|
227
268
|
// With interception or an async provider, that starts once the spec has been served.
|
|
228
269
|
if (openapi && openapi.document !== undefined && typeof openapi.document !== "function")
|
|
229
270
|
enrich(openapi.document);
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
method: "
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
+
};
|
|
247
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
|
+
}
|
|
248
324
|
const reply = (status, body, headers = FEEDBACK_HEADERS) => body === undefined ? { status, headers } : { status, headers, body };
|
|
249
325
|
const badRequest = (error) => reply(400, {
|
|
250
326
|
recorded: false,
|
|
251
327
|
error,
|
|
252
328
|
allowed: { outcome: Object.keys(OUTCOMES), issue: Object.keys(ISSUES) },
|
|
253
|
-
example: `${
|
|
329
|
+
example: `${linksFor().rate}?feedback_id=FEEDBACK_ID&outcome=fully`,
|
|
254
330
|
});
|
|
255
331
|
async function submit(request, input, via) {
|
|
256
332
|
const parsed = parseSubmission(input, { allowNote: via === "POST" });
|
|
@@ -294,6 +370,7 @@ function enabledCore(options, configWarnings) {
|
|
|
294
370
|
return reply(summaryCache.status, summaryCache.body, { "Cache-Control": "public, max-age=30" });
|
|
295
371
|
}
|
|
296
372
|
async function route(request) {
|
|
373
|
+
refreshRegisteredOrigin();
|
|
297
374
|
try {
|
|
298
375
|
return await handleRoute(request);
|
|
299
376
|
}
|
|
@@ -307,7 +384,7 @@ function enabledCore(options, configWarnings) {
|
|
|
307
384
|
const { method, path } = request;
|
|
308
385
|
if (feedback && path === basePath) {
|
|
309
386
|
if (method === "GET" || method === "HEAD")
|
|
310
|
-
return reply(200,
|
|
387
|
+
return reply(200, formDoc());
|
|
311
388
|
if (method === "POST") {
|
|
312
389
|
let body;
|
|
313
390
|
try {
|
|
@@ -342,6 +419,8 @@ function enabledCore(options, configWarnings) {
|
|
|
342
419
|
}
|
|
343
420
|
const passThrough = {
|
|
344
421
|
feedbackId: undefined,
|
|
422
|
+
contextRequired: false,
|
|
423
|
+
contextError: () => null,
|
|
345
424
|
json: (_status, body) => body,
|
|
346
425
|
text: (_status, _type, body) => body,
|
|
347
426
|
headers: () => ({}),
|
|
@@ -368,14 +447,20 @@ function enabledCore(options, configWarnings) {
|
|
|
368
447
|
const interactionId = paymentHeader && !feedback ? randomUUID() : undefined;
|
|
369
448
|
if (feedbackId)
|
|
370
449
|
stats.minted++;
|
|
371
|
-
|
|
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=` : "";
|
|
372
454
|
let bodyChallenge = false;
|
|
373
455
|
let headerChallenge = false;
|
|
374
456
|
let context;
|
|
457
|
+
let rawContext = {};
|
|
375
458
|
let receiptFacts = {};
|
|
376
459
|
const remember = (raw) => {
|
|
377
460
|
if (!collectContext)
|
|
378
461
|
return;
|
|
462
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw))
|
|
463
|
+
rawContext = { ...rawContext, ...raw };
|
|
379
464
|
const parsed = parseAgentContext(raw, { searchQuery });
|
|
380
465
|
if (parsed)
|
|
381
466
|
context = { ...context, ...parsed };
|
|
@@ -383,12 +468,24 @@ function enabledCore(options, configWarnings) {
|
|
|
383
468
|
const ok = (status) => status >= 200 && status < 300;
|
|
384
469
|
const handle = {
|
|
385
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
|
+
},
|
|
386
483
|
json(status, body) {
|
|
387
484
|
try {
|
|
388
485
|
if (status === 402) {
|
|
389
486
|
bodyChallenge = !!body && typeof body === "object" && "x402Version" in body;
|
|
390
487
|
// v1 challenges (and v2 challenges echoed in the body) live in the JSON body.
|
|
391
|
-
const described = touchChallenges ? describeChallengeBody(body, challengeAdditions) : undefined;
|
|
488
|
+
const described = touchChallenges ? describeChallengeBody(body, challengeAdditions(challengeOrigin(body))) : undefined;
|
|
392
489
|
if (described) {
|
|
393
490
|
stats.challengesDescribed++;
|
|
394
491
|
return described;
|
|
@@ -400,15 +497,16 @@ function enabledCore(options, configWarnings) {
|
|
|
400
497
|
body !== null &&
|
|
401
498
|
typeof body === "object" &&
|
|
402
499
|
Object.getPrototypeOf(body) === Object.prototype &&
|
|
403
|
-
!(
|
|
404
|
-
!("feedback_url" in body) &&
|
|
405
|
-
!(rateHint && "rate_this_call" in body) &&
|
|
500
|
+
!(FEEDBACK_FIELD in body) &&
|
|
406
501
|
(allowInjection?.(request.method, request.path, status) ?? true)) {
|
|
502
|
+
// One namespaced object, so the merchant's own fields stay recognizably theirs.
|
|
407
503
|
return {
|
|
408
504
|
...body,
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
505
|
+
[FEEDBACK_FIELD]: {
|
|
506
|
+
feedback_id: feedbackId,
|
|
507
|
+
rate: feedbackUrl,
|
|
508
|
+
...(rateHint ? { rate_this_call: rateHint.replaceAll("{feedback_url}", feedbackUrl).replaceAll("{summary_url}", callLinks.summary) } : {}),
|
|
509
|
+
},
|
|
412
510
|
};
|
|
413
511
|
}
|
|
414
512
|
}
|
|
@@ -430,7 +528,7 @@ function enabledCore(options, configWarnings) {
|
|
|
430
528
|
if (status === 402 && paymentRequired)
|
|
431
529
|
headerChallenge = true;
|
|
432
530
|
if (status === 402 && touchChallenges && paymentRequired) {
|
|
433
|
-
const next = describeChallenge(paymentRequired, challengeAdditions);
|
|
531
|
+
const next = describeChallenge(paymentRequired, challengeAdditions(challengeOrigin(paymentRequired)));
|
|
434
532
|
if (next) {
|
|
435
533
|
set["PAYMENT-REQUIRED"] = next;
|
|
436
534
|
stats.challengesDescribed++;
|
|
@@ -440,7 +538,7 @@ function enabledCore(options, configWarnings) {
|
|
|
440
538
|
receiptFacts = readReceipt(paymentResponse);
|
|
441
539
|
if (feedbackId && ok(status)) {
|
|
442
540
|
set["Forge-Feedback-Id"] = feedbackId;
|
|
443
|
-
const receipt = receipts && paymentResponse ? describeReceipt(paymentResponse, receiptExtension(
|
|
541
|
+
const receipt = receipts && paymentResponse ? describeReceipt(paymentResponse, receiptExtension(callLinks.rate, feedbackId, tone)) : undefined;
|
|
444
542
|
if (receipt)
|
|
445
543
|
set["PAYMENT-RESPONSE"] = receipt;
|
|
446
544
|
}
|
|
@@ -510,7 +608,9 @@ function enabledCore(options, configWarnings) {
|
|
|
510
608
|
}
|
|
511
609
|
return {
|
|
512
610
|
enabled: true,
|
|
513
|
-
challengeSentence
|
|
611
|
+
get challengeSentence() {
|
|
612
|
+
return sentenceFor(linksFor());
|
|
613
|
+
},
|
|
514
614
|
route,
|
|
515
615
|
isSpecRequest: (method, path) => Boolean(openapi) && method === "GET" && specPaths.has(path),
|
|
516
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. */
|
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; enabled context rejects oversized paid JSON before payment processing. */
|
|
5
5
|
const JSON_LIMIT = 1024 * 1024;
|
|
6
6
|
const isJson = (type) => /^application\/(?:[\w.+-]+\+)?json\b/i.test(type ?? "");
|
|
7
7
|
export function toResponse(response) {
|
|
@@ -20,6 +20,40 @@ const smallEnough = (headers, limit) => {
|
|
|
20
20
|
const length = Number(headers.get("content-length"));
|
|
21
21
|
return Number.isFinite(length) && length > 0 && length <= limit;
|
|
22
22
|
};
|
|
23
|
+
/** Bound reads even when a client sends chunked JSON or an inaccurate Content-Length. */
|
|
24
|
+
async function boundedJson(request) {
|
|
25
|
+
const reader = request.clone().body.getReader();
|
|
26
|
+
const chunks = [];
|
|
27
|
+
let size = 0;
|
|
28
|
+
try {
|
|
29
|
+
while (true) {
|
|
30
|
+
const { value, done } = await reader.read();
|
|
31
|
+
if (done)
|
|
32
|
+
break;
|
|
33
|
+
size += value.length;
|
|
34
|
+
if (size > JSON_LIMIT)
|
|
35
|
+
throw new Error("body_too_large");
|
|
36
|
+
chunks.push(value);
|
|
37
|
+
}
|
|
38
|
+
const data = new Uint8Array(size);
|
|
39
|
+
let offset = 0;
|
|
40
|
+
for (const chunk of chunks) {
|
|
41
|
+
data.set(chunk, offset);
|
|
42
|
+
offset += chunk.length;
|
|
43
|
+
}
|
|
44
|
+
return JSON.parse(new TextDecoder().decode(data));
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
// A clone is a tee: awaiting cancellation would wait for the untouched original stream.
|
|
48
|
+
void reader.cancel().catch(() => { });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function unreadableContext() {
|
|
52
|
+
return toResponse({ status: 400, headers: {}, body: {
|
|
53
|
+
error: "agent_context_invalid",
|
|
54
|
+
message: "Required agent context could not be read. Send valid JSON up to 1 MiB, or use agent_type and agent_search_query query parameters with a non-JSON body.",
|
|
55
|
+
} });
|
|
56
|
+
}
|
|
23
57
|
export function createForge(options) {
|
|
24
58
|
const core = createForgeCore(options);
|
|
25
59
|
const forgeRequest = (request, url) => ({
|
|
@@ -35,6 +69,24 @@ export function createForge(options) {
|
|
|
35
69
|
const own = await core.route(forgeRequest(request, new URL(request.url)));
|
|
36
70
|
return own ? toResponse(own) : null;
|
|
37
71
|
}
|
|
72
|
+
async function validate(request) {
|
|
73
|
+
if (!core.enabled || options.agentContext === false)
|
|
74
|
+
return null;
|
|
75
|
+
const url = new URL(request.url);
|
|
76
|
+
const call = core.call(forgeRequest(request, url));
|
|
77
|
+
if (!call.contextRequired)
|
|
78
|
+
return null;
|
|
79
|
+
try {
|
|
80
|
+
call.requestUrl(`${url.pathname}${url.search}`);
|
|
81
|
+
if (request.body && isJson(request.headers.get("content-type")))
|
|
82
|
+
call.requestBody(await boundedJson(request));
|
|
83
|
+
const error = call.contextError();
|
|
84
|
+
return error ? toResponse(error) : null;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return unreadableContext();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
38
90
|
/** The merchant's own OpenAPI route: fetch it without validators, enrich JSON 200s, pass anything else through. */
|
|
39
91
|
async function spec(request, next) {
|
|
40
92
|
const headers = new Headers(request.headers);
|
|
@@ -63,7 +115,7 @@ export function createForge(options) {
|
|
|
63
115
|
async function handle(request, next) {
|
|
64
116
|
if (!core.enabled)
|
|
65
117
|
return next(request);
|
|
66
|
-
// Before the handler: our own routes, the spec, and agent context.
|
|
118
|
+
// Before the handler: our own routes, the spec, and agent context. Required-context errors stop paid attempts.
|
|
67
119
|
let call;
|
|
68
120
|
let forwarded = request;
|
|
69
121
|
try {
|
|
@@ -77,7 +129,13 @@ export function createForge(options) {
|
|
|
77
129
|
const stripped = call.requestUrl(`${url.pathname}${url.search}`);
|
|
78
130
|
const nextUrl = stripped === `${url.pathname}${url.search}` ? request.url : new URL(stripped, url).href;
|
|
79
131
|
let body;
|
|
80
|
-
if (request.body && isJson(request.headers.get("content-type"))
|
|
132
|
+
if (call.contextRequired && request.body && isJson(request.headers.get("content-type"))) {
|
|
133
|
+
const parsed = await boundedJson(request);
|
|
134
|
+
const without = call.requestBody(parsed);
|
|
135
|
+
if (without !== parsed)
|
|
136
|
+
body = JSON.stringify(without);
|
|
137
|
+
}
|
|
138
|
+
else if (request.body && isJson(request.headers.get("content-type")) && smallEnough(request.headers, JSON_LIMIT)) {
|
|
81
139
|
const text = await request.clone().text();
|
|
82
140
|
if (text.includes('"agent_context"')) {
|
|
83
141
|
const parsed = JSON.parse(text);
|
|
@@ -86,6 +144,11 @@ export function createForge(options) {
|
|
|
86
144
|
body = JSON.stringify(without);
|
|
87
145
|
}
|
|
88
146
|
}
|
|
147
|
+
const contextError = call.contextError();
|
|
148
|
+
if (contextError) {
|
|
149
|
+
call.finish(contextError.status);
|
|
150
|
+
return toResponse(contextError);
|
|
151
|
+
}
|
|
89
152
|
if (nextUrl !== request.url || body !== undefined) {
|
|
90
153
|
const headers = new Headers(request.headers);
|
|
91
154
|
if (body !== undefined)
|
|
@@ -99,6 +162,10 @@ export function createForge(options) {
|
|
|
99
162
|
}
|
|
100
163
|
catch (error) {
|
|
101
164
|
core.onError(error);
|
|
165
|
+
if (call?.contextRequired) {
|
|
166
|
+
call.finish(400);
|
|
167
|
+
return unreadableContext();
|
|
168
|
+
}
|
|
102
169
|
return next(request);
|
|
103
170
|
}
|
|
104
171
|
const response = await next(forwarded);
|
|
@@ -165,6 +232,7 @@ export function createForge(options) {
|
|
|
165
232
|
diagnostics: core.diagnostics,
|
|
166
233
|
shutdown: core.shutdown,
|
|
167
234
|
handle,
|
|
235
|
+
validate,
|
|
168
236
|
route,
|
|
169
237
|
wrap: (handler) => (request, ...rest) => handle(request, (r) => handler(r, ...rest)),
|
|
170
238
|
};
|
package/dist/hono.js
CHANGED
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)
|