@forgeintel/sdk 0.2.0-beta.0 → 0.4.0-beta.0
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 +37 -5
- package/dist/ask.d.ts +25 -0
- package/dist/ask.js +36 -0
- package/dist/context.d.ts +74 -0
- package/dist/context.js +117 -0
- package/dist/core.d.ts +42 -4
- package/dist/core.js +96 -19
- package/dist/express.js +43 -5
- package/dist/fetch.d.ts +17 -0
- package/dist/fetch.js +171 -0
- package/dist/hono.d.ts +10 -0
- package/dist/hono.js +35 -0
- package/dist/id.d.ts +5 -0
- package/dist/id.js +7 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -2
- package/dist/next.d.ts +25 -0
- package/dist/next.js +30 -0
- package/dist/openapi.d.ts +6 -0
- package/dist/openapi.js +55 -0
- package/dist/reporter.d.ts +7 -0
- package/dist/x402.d.ts +30 -4
- package/dist/x402.js +61 -4
- package/package.json +29 -11
package/dist/core.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
// Framework-free Forge logic. Adapters (express.ts, later fetch.ts) translate their request/response
|
|
2
2
|
// objects to the small interfaces below and write out what the core returns.
|
|
3
|
-
import { DEFAULT_TTL_MS, mintFeedbackId, verifyFeedbackId } from "./id.js";
|
|
3
|
+
import { DEFAULT_TTL_MS, deriveSigningKey, mintFeedbackId, verifyFeedbackId } from "./id.js";
|
|
4
4
|
import { createOperationIndex, enrichOpenApi } from "./openapi.js";
|
|
5
|
+
import { ASK, TONES, checkAskText } from "./ask.js";
|
|
6
|
+
import { agentContextAsk, parseAgentContext, takeFromBody, takeFromUrl } from "./context.js";
|
|
5
7
|
import { EventReporter } from "./reporter.js";
|
|
6
8
|
import { ISSUES, NOTE_MAX_LENGTH, OUTCOMES, PROTOCOL, parseSubmission } from "./values.js";
|
|
7
|
-
import { describeChallenge, describeChallengeBody, feedbackExtension, readPaymentHeader } from "./x402.js";
|
|
9
|
+
import { describeChallenge, describeChallengeBody, describeReceipt, feedbackExtension, readPaymentHeader, readReceipt, receiptExtension } from "./x402.js";
|
|
8
10
|
export const FEEDBACK_HEADERS = { "Cache-Control": "no-store", "X-Robots-Tag": "noindex" };
|
|
9
11
|
/** Max JSON body for POST {basePath}. */
|
|
10
12
|
export const BODY_LIMIT = 8 * 1024;
|
|
11
13
|
/** Max OpenAPI document an adapter should buffer for enrichment. */
|
|
12
14
|
export const SPEC_LIMIT = 10 * 1024 * 1024;
|
|
13
|
-
const DEFAULT_RATE_HINT = "Please rate this service for other agents: GET {feedback_url} with fully, partially or no appended (e.g. {feedback_url}fully). Free, one request.";
|
|
14
15
|
/**
|
|
15
16
|
* Check options without throwing. Invalid required options are errors (Forge runs disabled);
|
|
16
17
|
* invalid optional values are warnings and fall back to their defaults.
|
|
@@ -55,10 +56,21 @@ export function checkOptions(input) {
|
|
|
55
56
|
fallback("flushIntervalMs", positive(raw.flushIntervalMs), "must be a positive number of milliseconds");
|
|
56
57
|
fallback("fetch", typeof raw.fetch === "function", "must be a fetch function");
|
|
57
58
|
fallback("onError", typeof raw.onError === "function", "must be a function");
|
|
59
|
+
fallback("tone", TONES.includes(raw.tone), `must be one of ${TONES.map((t) => `"${t}"`).join(", ")}`);
|
|
58
60
|
fallback("challengeSentence", typeof raw.challengeSentence === "string" && raw.challengeSentence.trim() !== "", "must be a non-empty string");
|
|
59
61
|
fallback("rateHint", boolean(raw.rateHint) || typeof raw.rateHint === "string", "must be a boolean or a string");
|
|
60
|
-
|
|
62
|
+
fallback("agentContext", boolean(raw.agentContext) || (!!raw.agentContext && typeof raw.agentContext === "object" && !Array.isArray(raw.agentContext)), "must be true, false or { searchQuery }");
|
|
63
|
+
for (const key of ["describeChallenges", "challengeExtension", "receiptExtension", "injectBody", "injectText", "strict"])
|
|
61
64
|
fallback(key, boolean(raw[key]), "must be true or false");
|
|
65
|
+
// The lines no wording may cross, whatever the merchant configures (see ask.ts).
|
|
66
|
+
for (const key of ["challengeSentence", "rateHint"]) {
|
|
67
|
+
const text = options[key];
|
|
68
|
+
const problem = typeof text === "string" ? checkAskText(text) : null;
|
|
69
|
+
if (problem) {
|
|
70
|
+
warnings.push(`${key} ${problem}; Forge never presents the rating as required or conditional, or asks for user data. Using the default`);
|
|
71
|
+
delete options[key];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
62
74
|
if (raw.openapi !== undefined && raw.openapi !== false) {
|
|
63
75
|
const openapi = raw.openapi;
|
|
64
76
|
if (!openapi || typeof openapi !== "object")
|
|
@@ -81,7 +93,15 @@ export function checkOptions(input) {
|
|
|
81
93
|
}
|
|
82
94
|
/** Everything passes through untouched. Used when invalid options turned Forge off. */
|
|
83
95
|
function disabledCore(errors, warnings) {
|
|
84
|
-
const passThrough = {
|
|
96
|
+
const passThrough = {
|
|
97
|
+
feedbackId: undefined,
|
|
98
|
+
json: (_status, body) => body,
|
|
99
|
+
text: (_status, _type, body) => body,
|
|
100
|
+
headers: () => ({}),
|
|
101
|
+
requestBody: (body) => body,
|
|
102
|
+
requestUrl: (url) => url,
|
|
103
|
+
finish: () => { },
|
|
104
|
+
};
|
|
85
105
|
return {
|
|
86
106
|
enabled: false,
|
|
87
107
|
challengeSentence: "",
|
|
@@ -123,6 +143,10 @@ export function createForgeCore(input) {
|
|
|
123
143
|
}
|
|
124
144
|
function enabledCore(options, configWarnings) {
|
|
125
145
|
const { apiKey, backendUrl, publicUrl } = options;
|
|
146
|
+
// Feedback IDs are signed with a key derived from the API key, never with the API key itself.
|
|
147
|
+
const signingKey = deriveSigningKey(apiKey);
|
|
148
|
+
const backendPath = new URL(backendUrl).pathname.replace(/\/+$/, "") || "/v1";
|
|
149
|
+
const backend = (name) => new URL(`${backendPath}/${name}`, backendUrl).href;
|
|
126
150
|
const basePath = (options.basePath ?? "/feedback").replace(/\/+$/, "");
|
|
127
151
|
const ratePath = `${basePath}/rate`;
|
|
128
152
|
const rateUrl = new URL(ratePath, publicUrl).href;
|
|
@@ -134,11 +158,16 @@ function enabledCore(options, configWarnings) {
|
|
|
134
158
|
const describe = options.describeChallenges ?? true;
|
|
135
159
|
const injectBody = options.injectBody ?? true;
|
|
136
160
|
const injectText = options.injectText ?? false;
|
|
161
|
+
const tone = options.tone ?? "soft";
|
|
162
|
+
const contextOption = options.agentContext ?? true;
|
|
163
|
+
const collectContext = contextOption !== false;
|
|
164
|
+
const searchQuery = collectContext && (typeof contextOption !== "object" || contextOption.searchQuery !== false);
|
|
165
|
+
const receipts = options.receiptExtension ?? true;
|
|
137
166
|
const rateHint = options.rateHint === false
|
|
138
167
|
? null
|
|
139
168
|
: typeof options.rateHint === "string" && options.rateHint.trim()
|
|
140
169
|
? options.rateHint
|
|
141
|
-
:
|
|
170
|
+
: ASK[tone].rateHint;
|
|
142
171
|
const openapi = options.openapi === false ? null : (options.openapi ?? {});
|
|
143
172
|
const specPaths = new Set(openapi?.paths ?? ["/openapi.json"]);
|
|
144
173
|
const stats = {
|
|
@@ -156,14 +185,13 @@ function enabledCore(options, configWarnings) {
|
|
|
156
185
|
console.warn(`[forge-feedback] ${message}`);
|
|
157
186
|
lastLogged = message;
|
|
158
187
|
});
|
|
159
|
-
const reporter = new EventReporter(
|
|
160
|
-
const challengeSentence = options.challengeSentence
|
|
161
|
-
`After your call, please rate this service for other agents: GET ${rateUrl}?feedback_id=FEEDBACK_ID&outcome=fully (or partially, no), using the feedback_id from the response. Free, one request.`;
|
|
188
|
+
const reporter = new EventReporter(backend("events"), apiKey, fetchImpl, onError, options.flushIntervalMs ?? 2000);
|
|
189
|
+
const challengeSentence = (options.challengeSentence ?? ASK[tone].challengeSentence).replaceAll("{rate_url}", rateUrl).replaceAll("{summary_url}", summaryUrl);
|
|
162
190
|
// Idempotency marker: the rate URL when the sentence contains it, otherwise the sentence itself.
|
|
163
191
|
const challengeMarker = challengeSentence.includes(rateUrl) ? rateUrl : challengeSentence;
|
|
164
192
|
const challengeAdditions = {
|
|
165
193
|
...(describe ? { sentence: challengeSentence, marker: challengeMarker } : {}),
|
|
166
|
-
...(options.challengeExtension === false ? {} : { extension: feedbackExtension(rateUrl) }),
|
|
194
|
+
...(options.challengeExtension === false ? {} : { extension: feedbackExtension(rateUrl, tone, collectContext ? agentContextAsk({ searchQuery }) : undefined) }),
|
|
167
195
|
};
|
|
168
196
|
const touchChallenges = Boolean(challengeAdditions.sentence || challengeAdditions.extension);
|
|
169
197
|
// Set once a document has been enriched; lets body injection respect strict response schemas.
|
|
@@ -178,6 +206,7 @@ function enabledCore(options, configWarnings) {
|
|
|
178
206
|
isPaidOperation: openapi?.isPaidOperation,
|
|
179
207
|
describeOperations: openapi?.describeOperations,
|
|
180
208
|
hintField: Boolean(rateHint),
|
|
209
|
+
agentContext: collectContext ? { searchQuery } : undefined,
|
|
181
210
|
});
|
|
182
211
|
stats.openapi = result.report;
|
|
183
212
|
if (result.report.enriched)
|
|
@@ -222,11 +251,11 @@ function enabledCore(options, configWarnings) {
|
|
|
222
251
|
if (!parsed.ok)
|
|
223
252
|
return badRequest(parsed.error);
|
|
224
253
|
// Cheap local check so garbage and expired IDs never reach the backend.
|
|
225
|
-
if (!verifyFeedbackId(
|
|
254
|
+
if (!verifyFeedbackId(signingKey, parsed.value.feedback_id, { ttlMs }).valid) {
|
|
226
255
|
return reply(404, { recorded: false, error: "unknown_or_expired_feedback_id" });
|
|
227
256
|
}
|
|
228
257
|
try {
|
|
229
|
-
const upstream = await fetchImpl(
|
|
258
|
+
const upstream = await fetchImpl(backend("feedback"), {
|
|
230
259
|
method: "POST",
|
|
231
260
|
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
232
261
|
body: JSON.stringify({ ...parsed.value, via, user_agent: request.header("user-agent") ?? null }),
|
|
@@ -245,7 +274,7 @@ function enabledCore(options, configWarnings) {
|
|
|
245
274
|
async function summary() {
|
|
246
275
|
if (!summaryCache || Date.now() - summaryCache.at > 30_000) {
|
|
247
276
|
try {
|
|
248
|
-
const upstream = await fetchImpl(
|
|
277
|
+
const upstream = await fetchImpl(backend("summary"), {
|
|
249
278
|
headers: { authorization: `Bearer ${apiKey}` },
|
|
250
279
|
signal: AbortSignal.timeout(3000),
|
|
251
280
|
});
|
|
@@ -301,7 +330,15 @@ function enabledCore(options, configWarnings) {
|
|
|
301
330
|
}
|
|
302
331
|
return null;
|
|
303
332
|
}
|
|
304
|
-
const passThrough = {
|
|
333
|
+
const passThrough = {
|
|
334
|
+
feedbackId: undefined,
|
|
335
|
+
json: (_status, body) => body,
|
|
336
|
+
text: (_status, _type, body) => body,
|
|
337
|
+
headers: () => ({}),
|
|
338
|
+
requestBody: (body) => body,
|
|
339
|
+
requestUrl: (url) => url,
|
|
340
|
+
finish: () => { },
|
|
341
|
+
};
|
|
305
342
|
function call(request) {
|
|
306
343
|
try {
|
|
307
344
|
return startCall(request);
|
|
@@ -316,12 +353,21 @@ function enabledCore(options, configWarnings) {
|
|
|
316
353
|
const route = `${request.method} ${request.path}`;
|
|
317
354
|
const userAgent = request.header("user-agent") ?? undefined;
|
|
318
355
|
const paymentHeader = request.header("payment-signature") ?? request.header("x-payment"); // x402 v2 / v1
|
|
319
|
-
const feedbackId = paymentHeader ? mintFeedbackId(
|
|
356
|
+
const feedbackId = paymentHeader ? mintFeedbackId(signingKey) : undefined;
|
|
320
357
|
if (feedbackId)
|
|
321
358
|
stats.minted++;
|
|
322
359
|
const feedbackUrl = feedbackId ? `${rateUrl}?feedback_id=${feedbackId}&outcome=` : "";
|
|
323
360
|
let bodyChallenge = false;
|
|
324
361
|
let headerChallenge = false;
|
|
362
|
+
let context;
|
|
363
|
+
let receiptFacts = {};
|
|
364
|
+
const remember = (raw) => {
|
|
365
|
+
if (!collectContext)
|
|
366
|
+
return;
|
|
367
|
+
const parsed = parseAgentContext(raw, { searchQuery });
|
|
368
|
+
if (parsed)
|
|
369
|
+
context = { ...context, ...parsed };
|
|
370
|
+
};
|
|
325
371
|
const ok = (status) => status >= 200 && status < 300;
|
|
326
372
|
const handle = {
|
|
327
373
|
feedbackId,
|
|
@@ -366,7 +412,7 @@ function enabledCore(options, configWarnings) {
|
|
|
366
412
|
return body;
|
|
367
413
|
return `${body}${body.endsWith("\n") ? "" : "\n"}\nfeedback_id: ${feedbackId}\nfeedback_url: ${feedbackUrl}\n`;
|
|
368
414
|
},
|
|
369
|
-
headers(status, paymentRequired) {
|
|
415
|
+
headers(status, paymentRequired, paymentResponse) {
|
|
370
416
|
const set = {};
|
|
371
417
|
try {
|
|
372
418
|
if (status === 402 && paymentRequired)
|
|
@@ -378,14 +424,44 @@ function enabledCore(options, configWarnings) {
|
|
|
378
424
|
stats.challengesDescribed++;
|
|
379
425
|
}
|
|
380
426
|
}
|
|
381
|
-
if (feedbackId && ok(status))
|
|
427
|
+
if (feedbackId && ok(status)) {
|
|
382
428
|
set["Forge-Feedback-Id"] = feedbackId;
|
|
429
|
+
if (paymentResponse)
|
|
430
|
+
receiptFacts = readReceipt(paymentResponse);
|
|
431
|
+
const receipt = receipts && paymentResponse ? describeReceipt(paymentResponse, receiptExtension(rateUrl, feedbackId, tone)) : undefined;
|
|
432
|
+
if (receipt)
|
|
433
|
+
set["PAYMENT-RESPONSE"] = receipt;
|
|
434
|
+
}
|
|
383
435
|
}
|
|
384
436
|
catch (error) {
|
|
385
437
|
onError(error);
|
|
386
438
|
}
|
|
387
439
|
return set;
|
|
388
440
|
},
|
|
441
|
+
requestBody(body) {
|
|
442
|
+
try {
|
|
443
|
+
const taken = takeFromBody(body);
|
|
444
|
+
if ("raw" in taken)
|
|
445
|
+
remember(taken.raw);
|
|
446
|
+
return taken.body;
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
onError(error);
|
|
450
|
+
return body;
|
|
451
|
+
}
|
|
452
|
+
},
|
|
453
|
+
requestUrl(url) {
|
|
454
|
+
try {
|
|
455
|
+
const taken = takeFromUrl(url);
|
|
456
|
+
if (taken.raw)
|
|
457
|
+
remember(taken.raw);
|
|
458
|
+
return taken.url;
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
onError(error);
|
|
462
|
+
return url;
|
|
463
|
+
}
|
|
464
|
+
},
|
|
389
465
|
finish(status, hadChallengeHeader = false) {
|
|
390
466
|
try {
|
|
391
467
|
report(status, hadChallengeHeader);
|
|
@@ -399,10 +475,10 @@ function enabledCore(options, configWarnings) {
|
|
|
399
475
|
const ts = Date.now();
|
|
400
476
|
if (status === 402 && (hadChallengeHeader || headerChallenge || bodyChallenge)) {
|
|
401
477
|
// v2 carries the challenge in a header; v1 in the body. Both count as a challenge.
|
|
402
|
-
reporter.push({ type: "challenge", route, ts, user_agent: userAgent });
|
|
478
|
+
reporter.push({ type: "challenge", route, ts, user_agent: userAgent, ...(context ? { agent_context: context } : {}) });
|
|
403
479
|
}
|
|
404
480
|
if (feedbackId) {
|
|
405
|
-
const facts = readPaymentHeader(paymentHeader);
|
|
481
|
+
const facts = { ...readPaymentHeader(paymentHeader), ...receiptFacts };
|
|
406
482
|
reporter.push({
|
|
407
483
|
type: "interaction",
|
|
408
484
|
feedback_id: feedbackId,
|
|
@@ -412,6 +488,7 @@ function enabledCore(options, configWarnings) {
|
|
|
412
488
|
// Only report the payer once the call succeeded (i.e. settlement went through).
|
|
413
489
|
...(ok(status) ? facts : { network: facts.network, amount: facts.amount }),
|
|
414
490
|
user_agent: userAgent,
|
|
491
|
+
...(context ? { agent_context: context } : {}),
|
|
415
492
|
ts,
|
|
416
493
|
});
|
|
417
494
|
}
|
package/dist/express.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CONTEXT_QUERY } from "./context.js";
|
|
1
2
|
import { BODY_LIMIT, SPEC_LIMIT, createForgeCore } from "./core.js";
|
|
2
3
|
/** Never throws (unless `strict`): with invalid options it warns and returns a middleware that only calls next(). */
|
|
3
4
|
export function createForge(options) {
|
|
@@ -97,8 +98,40 @@ export function createForge(options) {
|
|
|
97
98
|
return end.call(res, body, done);
|
|
98
99
|
};
|
|
99
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Agent context is read and removed before the merchant's validators and handlers run, so their API contract
|
|
103
|
+
* never changes. Query parameters: rewrite req.url (Express 5 re-reads req.query from it; Express 4 parsed
|
|
104
|
+
* req.query already, so delete them there too). Body: intercept the parser's `req.body = …` assignment,
|
|
105
|
+
* which leaves the request stream untouched for raw-body routes.
|
|
106
|
+
*/
|
|
107
|
+
function takeAgentContext(req, call) {
|
|
108
|
+
const url = call.requestUrl(req.url);
|
|
109
|
+
if (url !== req.url) {
|
|
110
|
+
req.url = url;
|
|
111
|
+
const query = Object.getOwnPropertyDescriptor(req, "query");
|
|
112
|
+
if (query && "value" in query && query.value && typeof query.value === "object") {
|
|
113
|
+
for (const name of Object.values(CONTEXT_QUERY))
|
|
114
|
+
delete query.value[name];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
let body = call.requestBody(req.body);
|
|
118
|
+
Object.defineProperty(req, "body", {
|
|
119
|
+
configurable: true,
|
|
120
|
+
enumerable: true,
|
|
121
|
+
get: () => body,
|
|
122
|
+
set: (value) => {
|
|
123
|
+
body = call.requestBody(value);
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
100
127
|
function observe(req, res) {
|
|
101
128
|
const call = core.call({ method: req.method, path: req.originalUrl.split("?")[0], header: (name) => req.get(name) });
|
|
129
|
+
try {
|
|
130
|
+
takeAgentContext(req, call);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
core.onError(error);
|
|
134
|
+
}
|
|
102
135
|
const originalJson = res.json.bind(res);
|
|
103
136
|
res.json = ((body) => originalJson(call.json(res.statusCode, body)));
|
|
104
137
|
if (call.feedbackId) {
|
|
@@ -120,11 +153,16 @@ export function createForge(options) {
|
|
|
120
153
|
try {
|
|
121
154
|
// Headers may be set via setHeader (Express) or passed straight to writeHead.
|
|
122
155
|
const inline = rest.find((a) => !!a && typeof a === "object" && !Array.isArray(a));
|
|
123
|
-
const inlineKey = inline && Object.keys(inline).find((k) => k.toLowerCase() ===
|
|
124
|
-
const current =
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
156
|
+
const inlineKey = (name) => inline && Object.keys(inline).find((k) => k.toLowerCase() === name.toLowerCase());
|
|
157
|
+
const current = (name) => {
|
|
158
|
+
const key = inlineKey(name);
|
|
159
|
+
const value = key ? inline[key] : res.getHeader(name);
|
|
160
|
+
return typeof value === "string" ? value : undefined;
|
|
161
|
+
};
|
|
162
|
+
for (const [name, value] of Object.entries(call.headers(statusCode, current("payment-required"), current("payment-response")))) {
|
|
163
|
+
const key = inlineKey(name);
|
|
164
|
+
if (key)
|
|
165
|
+
inline[key] = value;
|
|
128
166
|
else
|
|
129
167
|
res.setHeader(name, value);
|
|
130
168
|
}
|
package/dist/fetch.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ForgeCore, type ForgeOptions, type ForgeResponse } from "./core.js";
|
|
2
|
+
export type { ForgeOptions, OpenApiOptions } from "./core.js";
|
|
3
|
+
type Next = (request: Request) => Response | Promise<Response>;
|
|
4
|
+
export interface ForgeFetch extends Pick<ForgeCore, "enabled" | "challengeSentence" | "enrichOpenApi" | "diagnostics" | "shutdown"> {
|
|
5
|
+
/**
|
|
6
|
+
* Run one request through Forge around `next` (your handler, including your x402 payment middleware).
|
|
7
|
+
* Answers Forge's own routes, removes agent context from the request, and decorates the 402 and the paid response.
|
|
8
|
+
* Fails open: on any internal error the request reaches `next` unchanged, or its response is returned unchanged.
|
|
9
|
+
*/
|
|
10
|
+
handle(request: Request, next: Next): Promise<Response>;
|
|
11
|
+
/** Wrap a fetch handler, e.g. `export default { fetch: forge.wrap(app.fetch) }`. Extra arguments (env, ctx) pass through. */
|
|
12
|
+
wrap<A extends unknown[]>(handler: (request: Request, ...rest: A) => Response | Promise<Response>): (request: Request, ...rest: A) => Promise<Response>;
|
|
13
|
+
/** Forge's own routes only (/feedback, /feedback/rate, /feedback/summary, a static openapi.document); null for anything else. */
|
|
14
|
+
route(request: Request): Promise<Response | null>;
|
|
15
|
+
}
|
|
16
|
+
export declare function toResponse(response: ForgeResponse): Response;
|
|
17
|
+
export declare function createForge(options: ForgeOptions): ForgeFetch;
|
package/dist/fetch.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Web-standard adapter: Request in, Response out. Works wherever handlers are (request) => Response:
|
|
2
|
+
// Hono, Next.js route handlers, Cloudflare Workers, Bun, Deno. The Hono and Next adapters build on this.
|
|
3
|
+
import { BODY_LIMIT, SPEC_LIMIT, createForgeCore } from "./core.js";
|
|
4
|
+
/** Bodies Forge will buffer to remove agent_context or add feedback fields. Larger ones pass through untouched. */
|
|
5
|
+
const JSON_LIMIT = 1024 * 1024;
|
|
6
|
+
const isJson = (type) => /^application\/(?:[\w.+-]+\+)?json\b/i.test(type ?? "");
|
|
7
|
+
export function toResponse(response) {
|
|
8
|
+
if (response.body === undefined)
|
|
9
|
+
return new Response(null, { status: response.status, headers: response.headers });
|
|
10
|
+
return new Response(JSON.stringify(response.body), { status: response.status, headers: { ...response.headers, "Content-Type": "application/json; charset=utf-8" } });
|
|
11
|
+
}
|
|
12
|
+
async function readJson(request) {
|
|
13
|
+
const text = (await request.clone().text()).trim();
|
|
14
|
+
if (text.length > BODY_LIMIT)
|
|
15
|
+
throw new Error("body_too_large");
|
|
16
|
+
return text ? JSON.parse(text) : {};
|
|
17
|
+
}
|
|
18
|
+
/** Read a body only when it's declared small enough. Chunked bodies without a length are left alone. */
|
|
19
|
+
const smallEnough = (headers, limit) => {
|
|
20
|
+
const length = Number(headers.get("content-length"));
|
|
21
|
+
return Number.isFinite(length) && length > 0 && length <= limit;
|
|
22
|
+
};
|
|
23
|
+
export function createForge(options) {
|
|
24
|
+
const core = createForgeCore(options);
|
|
25
|
+
const forgeRequest = (request, url) => ({
|
|
26
|
+
method: request.method,
|
|
27
|
+
path: url.pathname,
|
|
28
|
+
header: (name) => request.headers.get(name) ?? undefined,
|
|
29
|
+
query: (name) => url.searchParams.get(name) ?? undefined,
|
|
30
|
+
json: () => readJson(request),
|
|
31
|
+
});
|
|
32
|
+
async function route(request) {
|
|
33
|
+
if (!core.enabled)
|
|
34
|
+
return null;
|
|
35
|
+
const own = await core.route(forgeRequest(request, new URL(request.url)));
|
|
36
|
+
return own ? toResponse(own) : null;
|
|
37
|
+
}
|
|
38
|
+
/** The merchant's own OpenAPI route: fetch it without validators, enrich JSON 200s, pass anything else through. */
|
|
39
|
+
async function spec(request, next) {
|
|
40
|
+
const headers = new Headers(request.headers);
|
|
41
|
+
for (const name of ["if-none-match", "if-modified-since", "accept-encoding"])
|
|
42
|
+
headers.delete(name);
|
|
43
|
+
const response = await next(new Request(request, { headers }));
|
|
44
|
+
try {
|
|
45
|
+
if (response.status !== 200 || response.headers.get("content-encoding") || !isJson(response.headers.get("content-type")))
|
|
46
|
+
return response;
|
|
47
|
+
const text = await response.clone().text();
|
|
48
|
+
if (text.length > SPEC_LIMIT)
|
|
49
|
+
return response;
|
|
50
|
+
const result = core.enrichOpenApi(JSON.parse(text));
|
|
51
|
+
if (!result.report.enriched)
|
|
52
|
+
return response;
|
|
53
|
+
const out = new Headers(response.headers);
|
|
54
|
+
for (const name of ["content-length", "etag", "last-modified"])
|
|
55
|
+
out.delete(name);
|
|
56
|
+
return new Response(JSON.stringify(result.document), { status: 200, statusText: response.statusText, headers: out });
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
core.onError(error);
|
|
60
|
+
return response;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function handle(request, next) {
|
|
64
|
+
if (!core.enabled)
|
|
65
|
+
return next(request);
|
|
66
|
+
// Before the handler: our own routes, the spec, and agent context. Any failure here: the original request goes on.
|
|
67
|
+
let call;
|
|
68
|
+
let forwarded = request;
|
|
69
|
+
try {
|
|
70
|
+
const url = new URL(request.url);
|
|
71
|
+
const own = await core.route(forgeRequest(request, url));
|
|
72
|
+
if (own)
|
|
73
|
+
return toResponse(own);
|
|
74
|
+
if (core.isSpecRequest(request.method, url.pathname))
|
|
75
|
+
return spec(request, next);
|
|
76
|
+
call = core.call({ method: request.method, path: url.pathname, header: (name) => request.headers.get(name) ?? undefined });
|
|
77
|
+
const stripped = call.requestUrl(`${url.pathname}${url.search}`);
|
|
78
|
+
const nextUrl = stripped === `${url.pathname}${url.search}` ? request.url : new URL(stripped, url).href;
|
|
79
|
+
let body;
|
|
80
|
+
if (request.body && isJson(request.headers.get("content-type")) && smallEnough(request.headers, JSON_LIMIT)) {
|
|
81
|
+
const text = await request.clone().text();
|
|
82
|
+
if (text.includes('"agent_context"')) {
|
|
83
|
+
const parsed = JSON.parse(text);
|
|
84
|
+
const without = call.requestBody(parsed);
|
|
85
|
+
if (without !== parsed)
|
|
86
|
+
body = JSON.stringify(without);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (nextUrl !== request.url || body !== undefined) {
|
|
90
|
+
const headers = new Headers(request.headers);
|
|
91
|
+
if (body !== undefined)
|
|
92
|
+
headers.delete("content-length");
|
|
93
|
+
forwarded = new Request(nextUrl, { method: request.method, headers, body: body ?? request.body, redirect: request.redirect, signal: request.signal, ...(body === undefined && request.body ? { duplex: "half" } : {}) });
|
|
94
|
+
// Cloudflare Workers attach request metadata as `cf`; keep it.
|
|
95
|
+
const cf = request.cf;
|
|
96
|
+
if (cf !== undefined)
|
|
97
|
+
Object.defineProperty(forwarded, "cf", { value: cf, enumerable: true });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
core.onError(error);
|
|
102
|
+
return next(request);
|
|
103
|
+
}
|
|
104
|
+
const response = await next(forwarded);
|
|
105
|
+
// After the handler: decorate the 402 or the paid response. Any failure before the body is read: the original
|
|
106
|
+
// response goes out. Once read, the body is always sent from what was read (decorated or not).
|
|
107
|
+
let added;
|
|
108
|
+
let read;
|
|
109
|
+
try {
|
|
110
|
+
added = call.headers(response.status, response.headers.get("payment-required") ?? undefined, response.headers.get("payment-response") ?? undefined);
|
|
111
|
+
const status = response.status;
|
|
112
|
+
const type = response.headers.get("content-type") ?? "";
|
|
113
|
+
const decorate = status === 402 || (call.feedbackId !== undefined && status >= 200 && status < 300);
|
|
114
|
+
const kind = isJson(type) || /^text\/plain\b/i.test(type);
|
|
115
|
+
// Only buffer JSON or plain text that is declared small, or has no declared length (never streams like SSE).
|
|
116
|
+
if (decorate && kind && response.body && (smallEnough(response.headers, JSON_LIMIT) || !response.headers.has("content-length"))) {
|
|
117
|
+
read = { text: await response.text(), type };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
core.onError(error);
|
|
122
|
+
call.finish(response.status);
|
|
123
|
+
return response;
|
|
124
|
+
}
|
|
125
|
+
let body = read?.text;
|
|
126
|
+
if (read) {
|
|
127
|
+
try {
|
|
128
|
+
if (isJson(read.type)) {
|
|
129
|
+
const parsed = JSON.parse(read.text);
|
|
130
|
+
const next = call.json(response.status, parsed);
|
|
131
|
+
if (next !== parsed)
|
|
132
|
+
body = JSON.stringify(next);
|
|
133
|
+
}
|
|
134
|
+
else
|
|
135
|
+
body = call.text(response.status, read.type, read.text);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
core.onError(error); // not JSON after all: send it as it was
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
call.finish(response.status);
|
|
142
|
+
if (read === undefined) {
|
|
143
|
+
if (!Object.keys(added).length)
|
|
144
|
+
return response;
|
|
145
|
+
try {
|
|
146
|
+
for (const [name, value] of Object.entries(added))
|
|
147
|
+
response.headers.set(name, value);
|
|
148
|
+
return response;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// Immutable headers (e.g. a fetch() response): rebuild around the same body stream.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const headers = new Headers(response.headers);
|
|
155
|
+
for (const [name, value] of Object.entries(added))
|
|
156
|
+
headers.set(name, value);
|
|
157
|
+
if (read !== undefined)
|
|
158
|
+
headers.delete("content-length");
|
|
159
|
+
return new Response(read === undefined ? response.body : body, { status: response.status, statusText: response.statusText, headers });
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
enabled: core.enabled,
|
|
163
|
+
challengeSentence: core.challengeSentence,
|
|
164
|
+
enrichOpenApi: core.enrichOpenApi,
|
|
165
|
+
diagnostics: core.diagnostics,
|
|
166
|
+
shutdown: core.shutdown,
|
|
167
|
+
handle,
|
|
168
|
+
route,
|
|
169
|
+
wrap: (handler) => (request, ...rest) => handle(request, (r) => handler(r, ...rest)),
|
|
170
|
+
};
|
|
171
|
+
}
|
package/dist/hono.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { ForgeOptions } from "./core.js";
|
|
3
|
+
import { type ForgeFetch } from "./fetch.js";
|
|
4
|
+
export type { ForgeOptions, OpenApiOptions } from "./core.js";
|
|
5
|
+
export interface Forge extends Omit<ForgeFetch, "handle" | "wrap" | "route"> {
|
|
6
|
+
/** Mount once, before your x402 payment middleware (`@x402/hono`) and before your OpenAPI route. */
|
|
7
|
+
middleware(): MiddlewareHandler;
|
|
8
|
+
}
|
|
9
|
+
/** Never throws (unless `strict`): with invalid options it warns and returns a middleware that only calls next(). */
|
|
10
|
+
export declare function createForge(options: ForgeOptions): Forge;
|
package/dist/hono.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createForge as createForgeFetch } from "./fetch.js";
|
|
2
|
+
/** Never throws (unless `strict`): with invalid options it warns and returns a middleware that only calls next(). */
|
|
3
|
+
export function createForge(options) {
|
|
4
|
+
const forge = createForgeFetch(options);
|
|
5
|
+
return {
|
|
6
|
+
enabled: forge.enabled,
|
|
7
|
+
challengeSentence: forge.challengeSentence,
|
|
8
|
+
enrichOpenApi: forge.enrichOpenApi,
|
|
9
|
+
diagnostics: forge.diagnostics,
|
|
10
|
+
shutdown: forge.shutdown,
|
|
11
|
+
middleware() {
|
|
12
|
+
if (!forge.enabled)
|
|
13
|
+
return async (_c, next) => next();
|
|
14
|
+
return async (c, next) => {
|
|
15
|
+
let ranNext = false;
|
|
16
|
+
const response = await forge.handle(c.req.raw, async (request) => {
|
|
17
|
+
// Hono reads url, headers and body from c.req.raw on demand, so handlers see the request without agent context.
|
|
18
|
+
if (request !== c.req.raw)
|
|
19
|
+
c.req.raw = request;
|
|
20
|
+
ranNext = true;
|
|
21
|
+
await next();
|
|
22
|
+
return c.res;
|
|
23
|
+
});
|
|
24
|
+
if (!ranNext)
|
|
25
|
+
return response; // one of Forge's own routes
|
|
26
|
+
if (response !== c.res) {
|
|
27
|
+
// Hono's c.res setter copies the previous response's headers onto the new one, which would undo
|
|
28
|
+
// Forge's rewritten PAYMENT-REQUIRED / PAYMENT-RESPONSE. Clear it first.
|
|
29
|
+
c.res = undefined;
|
|
30
|
+
c.res = response;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
package/dist/id.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export declare const FEEDBACK_ID_PATTERN: RegExp;
|
|
2
2
|
export declare const DEFAULT_TTL_MS: number;
|
|
3
|
+
/**
|
|
4
|
+
* The key feedback IDs are signed with, derived from the merchant's API key. Backends keep this (encrypted)
|
|
5
|
+
* instead of the API key itself, or derive it from the Bearer key on each request.
|
|
6
|
+
*/
|
|
7
|
+
export declare function deriveSigningKey(apiKey: string): string;
|
|
3
8
|
export declare function mintFeedbackId(key: string, now?: number): string;
|
|
4
9
|
export type FeedbackIdCheck = {
|
|
5
10
|
valid: true;
|
package/dist/id.js
CHANGED
|
@@ -5,6 +5,13 @@ export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
|
5
5
|
const HEAD_BYTES = 10;
|
|
6
6
|
const TAG_BYTES = 6;
|
|
7
7
|
const CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
8
|
+
/**
|
|
9
|
+
* The key feedback IDs are signed with, derived from the merchant's API key. Backends keep this (encrypted)
|
|
10
|
+
* instead of the API key itself, or derive it from the Bearer key on each request.
|
|
11
|
+
*/
|
|
12
|
+
export function deriveSigningKey(apiKey) {
|
|
13
|
+
return createHmac("sha256", apiKey).update("forge-feedback-id/v1").digest("base64url");
|
|
14
|
+
}
|
|
8
15
|
function tag(key, head) {
|
|
9
16
|
return createHmac("sha256", key).update(head).digest().subarray(0, TAG_BYTES);
|
|
10
17
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,10 +4,14 @@ export { createForgeCore, checkOptions, BODY_LIMIT, SPEC_LIMIT } from "./core.js
|
|
|
4
4
|
export type { ForgeCall, ForgeCore, ForgeDiagnostics, ForgeFeedbackOptions, ForgeOptions, ForgeRequest, ForgeResponse, OpenApiOptions } from "./core.js";
|
|
5
5
|
export { enrichOpenApi, detectVersion } from "./openapi.js";
|
|
6
6
|
export type { EnrichOptions, EnrichReport, OperationReport, ResponseSupport, SpecVersion } from "./openapi.js";
|
|
7
|
-
export { mintFeedbackId, verifyFeedbackId, FEEDBACK_ID_PATTERN, DEFAULT_TTL_MS } from "./id.js";
|
|
7
|
+
export { deriveSigningKey, mintFeedbackId, verifyFeedbackId, FEEDBACK_ID_PATTERN, DEFAULT_TTL_MS } from "./id.js";
|
|
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, feedbackExtension, FEEDBACK_EXTENSION, readPaymentHeader } from "./x402.js";
|
|
11
|
+
export { describeChallenge, describeChallengeBody, describeReceipt, feedbackExtension, receiptExtension, FEEDBACK_EXTENSION, readPaymentHeader, readReceipt } from "./x402.js";
|
|
12
|
+
export { ASK, TONES, checkAskText } from "./ask.js";
|
|
13
|
+
export { AGENT_TYPES, CLIENTS, CONTEXT_FIELD, CONTEXT_QUERY, agentContextSchema, parseAgentContext } from "./context.js";
|
|
14
|
+
export type { AgentContext } from "./context.js";
|
|
15
|
+
export type { AskTexts, Tone } from "./ask.js";
|
|
12
16
|
export type { ChallengeAdditions } from "./x402.js";
|
|
13
17
|
export type { ForgeEvent } from "./reporter.js";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { createForge, createForgeFeedback } from "./express.js";
|
|
2
2
|
export { createForgeCore, checkOptions, BODY_LIMIT, SPEC_LIMIT } from "./core.js";
|
|
3
3
|
export { enrichOpenApi, detectVersion } from "./openapi.js";
|
|
4
|
-
export { mintFeedbackId, verifyFeedbackId, FEEDBACK_ID_PATTERN, DEFAULT_TTL_MS } from "./id.js";
|
|
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, feedbackExtension, FEEDBACK_EXTENSION, readPaymentHeader } from "./x402.js";
|
|
6
|
+
export { describeChallenge, describeChallengeBody, describeReceipt, feedbackExtension, receiptExtension, FEEDBACK_EXTENSION, readPaymentHeader, readReceipt } from "./x402.js";
|
|
7
|
+
export { ASK, TONES, checkAskText } from "./ask.js";
|
|
8
|
+
export { AGENT_TYPES, CLIENTS, CONTEXT_FIELD, CONTEXT_QUERY, agentContextSchema, parseAgentContext } from "./context.js";
|
package/dist/next.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ForgeOptions } from "./core.js";
|
|
2
|
+
import { type ForgeFetch } from "./fetch.js";
|
|
3
|
+
export type { ForgeOptions, OpenApiOptions } from "./core.js";
|
|
4
|
+
export interface Forge extends Omit<ForgeFetch, "wrap" | "route"> {
|
|
5
|
+
/**
|
|
6
|
+
* Wrap a route handler, outermost: `export const GET = forge.withForge(withX402(handler, route, server))`.
|
|
7
|
+
* The handler receives the request Forge forwarded (without agent context); the 402 and the paid response are decorated.
|
|
8
|
+
*/
|
|
9
|
+
withForge<R extends Request, C = unknown>(handler: (request: R, context: C) => Response | Promise<Response>): (request: R, context: C) => Promise<Response>;
|
|
10
|
+
/**
|
|
11
|
+
* Handlers for Forge's own routes. In app/feedback/[[...path]]/route.ts: `export const { GET, POST } = forge.routes;`
|
|
12
|
+
* (and in app/openapi.json/route.ts when you pass `openapi.document`). Anything else answers 404.
|
|
13
|
+
*/
|
|
14
|
+
routes: {
|
|
15
|
+
GET: (request: Request) => Promise<Response>;
|
|
16
|
+
POST: (request: Request) => Promise<Response>;
|
|
17
|
+
HEAD: (request: Request) => Promise<Response>;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Wrap an x402 proxy (`paymentProxy`) so its 402 challenges carry the rating ask. Paid responses still need
|
|
21
|
+
* withForge on the route: the proxy never sees the route's body.
|
|
22
|
+
*/
|
|
23
|
+
proxy<R extends Request>(proxy: (request: R) => Response | Promise<Response>): (request: R) => Promise<Response>;
|
|
24
|
+
}
|
|
25
|
+
export declare function createForge(options: ForgeOptions): Forge;
|