@forgeintel/sdk 0.2.0-beta.0 → 0.3.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 +35 -3
- 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 +38 -3
- package/dist/core.js +82 -12
- 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/index.d.ts +5 -1
- package/dist/index.js +3 -1
- 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 +3 -0
- package/dist/x402.d.ts +23 -3
- package/dist/x402.js +41 -3
- package/package.json +29 -11
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/index.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ export { mintFeedbackId, verifyFeedbackId, FEEDBACK_ID_PATTERN, DEFAULT_TTL_MS }
|
|
|
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 } 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
|
@@ -3,4 +3,6 @@ export { createForgeCore, checkOptions, BODY_LIMIT, SPEC_LIMIT } from "./core.js
|
|
|
3
3
|
export { enrichOpenApi, detectVersion } from "./openapi.js";
|
|
4
4
|
export { 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 } 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;
|
package/dist/next.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createForge as createForgeFetch } from "./fetch.js";
|
|
2
|
+
/**
|
|
3
|
+
* Rebuild a forwarded Request as the original's class (NextRequest), so handlers and withX402 still get
|
|
4
|
+
* `nextUrl` and friends. Next isn't imported: the constructor comes from the request itself.
|
|
5
|
+
*/
|
|
6
|
+
function sameKind(original, forwarded) {
|
|
7
|
+
const Kind = original.constructor;
|
|
8
|
+
return Kind === Request ? forwarded : new Kind(forwarded);
|
|
9
|
+
}
|
|
10
|
+
export function createForge(options) {
|
|
11
|
+
const forge = createForgeFetch(options);
|
|
12
|
+
const own = async (request) => (await forge.route(request)) ?? new Response(JSON.stringify({ error: "not_found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
13
|
+
return {
|
|
14
|
+
enabled: forge.enabled,
|
|
15
|
+
challengeSentence: forge.challengeSentence,
|
|
16
|
+
enrichOpenApi: forge.enrichOpenApi,
|
|
17
|
+
diagnostics: forge.diagnostics,
|
|
18
|
+
shutdown: forge.shutdown,
|
|
19
|
+
handle: forge.handle,
|
|
20
|
+
withForge: (handler) => (request, context) => forge.handle(request, (forwarded) => handler(forwarded === request ? request : sameKind(request, forwarded), context)),
|
|
21
|
+
routes: { GET: own, POST: own, HEAD: own },
|
|
22
|
+
proxy: (proxy) => async (request) => {
|
|
23
|
+
const response = await proxy(request);
|
|
24
|
+
// Only the 402: the proxy passes paid requests on to the route, where withForge mints the feedback ID.
|
|
25
|
+
if (response.status !== 402 || !forge.enabled)
|
|
26
|
+
return response;
|
|
27
|
+
return forge.handle(request, () => response);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
package/dist/openapi.d.ts
CHANGED
|
@@ -16,6 +16,10 @@ export interface EnrichOptions {
|
|
|
16
16
|
describeOperations?: boolean;
|
|
17
17
|
/** Also document the optional rate_this_call body field (when the SDK's rateHint is on). */
|
|
18
18
|
hintField?: boolean;
|
|
19
|
+
/** Document optional agent context on paid operations: `agent_context` in JSON request bodies, agent_* query parameters otherwise. */
|
|
20
|
+
agentContext?: {
|
|
21
|
+
searchQuery: boolean;
|
|
22
|
+
};
|
|
19
23
|
}
|
|
20
24
|
export interface OperationReport {
|
|
21
25
|
method: string;
|
|
@@ -23,6 +27,8 @@ export interface OperationReport {
|
|
|
23
27
|
path: string;
|
|
24
28
|
/** Per 2xx status code: whether feedback_id was added to its JSON schema. */
|
|
25
29
|
responses: Record<string, ResponseSupport>;
|
|
30
|
+
/** Where optional agent context was documented, if asked to. */
|
|
31
|
+
agentContext?: "body" | "query" | "not_added";
|
|
26
32
|
reasons: string[];
|
|
27
33
|
}
|
|
28
34
|
export interface EnrichReport {
|
package/dist/openapi.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Additive, idempotent OpenAPI enrichment for Swagger 2.0 and OpenAPI 3.0 / 3.1 / 3.2.
|
|
2
2
|
// Rules: never mutate the input, never modify a shared $ref target, never override merchant paths,
|
|
3
3
|
// and on any unexpected input return the original document unchanged.
|
|
4
|
+
import { CONTEXT_FIELD, agentContextParameters, agentContextSchema } from "./context.js";
|
|
4
5
|
import { FEEDBACK_ID_PATTERN } from "./id.js";
|
|
5
6
|
import { ISSUES, NOTE_MAX_LENGTH, OUTCOMES, PROTOCOL } from "./values.js";
|
|
6
7
|
const METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
|
|
@@ -98,6 +99,58 @@ function extendSchema(doc, schema, props) {
|
|
|
98
99
|
copy.properties = { ...(isObj(copy.properties) ? copy.properties : {}), ...clone(props) };
|
|
99
100
|
return { schema: copy };
|
|
100
101
|
}
|
|
102
|
+
const BODYLESS = new Set(["get", "head", "delete", "options", "trace"]);
|
|
103
|
+
/** Document optional agent context on one paid operation. Never required; skipped when a schema can't take it safely. */
|
|
104
|
+
function addAgentContext(doc, version, item, method, op, opts, report) {
|
|
105
|
+
const resolve = (v) => (isObj(v) && typeof v.$ref === "string" ? resolveRef(doc, v.$ref) : v);
|
|
106
|
+
const listed = [...(Array.isArray(item.parameters) ? item.parameters : []), ...(Array.isArray(op.parameters) ? op.parameters : [])].map(resolve).filter(isObj);
|
|
107
|
+
const props = { [CONTEXT_FIELD]: agentContextSchema(opts) };
|
|
108
|
+
const notAdded = (reason) => {
|
|
109
|
+
report.agentContext = "not_added";
|
|
110
|
+
report.reasons.push(`request: ${reason}`);
|
|
111
|
+
};
|
|
112
|
+
if (BODYLESS.has(method)) {
|
|
113
|
+
const taken = new Set(listed.filter((p) => p.in === "query").map((p) => p.name));
|
|
114
|
+
const params = agentContextParameters(opts).filter((p) => !taken.has(p.name));
|
|
115
|
+
if (!params.length)
|
|
116
|
+
return notAdded("parameter_collision");
|
|
117
|
+
const documented = params.map((p) => version === "2.0"
|
|
118
|
+
? { name: p.name, in: "query", required: false, description: p.description, ...p.schema }
|
|
119
|
+
: { name: p.name, in: "query", required: false, description: p.description, schema: p.schema });
|
|
120
|
+
op.parameters = [...(Array.isArray(op.parameters) ? op.parameters : []), ...documented];
|
|
121
|
+
report.agentContext = "query";
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (version === "2.0") {
|
|
125
|
+
const params = Array.isArray(op.parameters) ? op.parameters : [];
|
|
126
|
+
const index = params.findIndex((p) => resolve(p)?.in === "body");
|
|
127
|
+
if (index < 0)
|
|
128
|
+
return notAdded("no_request_body");
|
|
129
|
+
const param = clone(resolve(params[index]));
|
|
130
|
+
const result = extendSchema(doc, param.schema, props);
|
|
131
|
+
if ("reason" in result)
|
|
132
|
+
return notAdded(result.reason);
|
|
133
|
+
param.schema = result.schema;
|
|
134
|
+
op.parameters = params.map((p, i) => (i === index ? param : p)); // inline copy if it was a shared $ref
|
|
135
|
+
report.agentContext = "body";
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const body = resolve(op.requestBody);
|
|
139
|
+
if (!isObj(body) || !isObj(body.content))
|
|
140
|
+
return notAdded("no_request_body");
|
|
141
|
+
const copy = clone(body);
|
|
142
|
+
const media = Object.entries(copy.content).filter(([type, entry]) => isJsonMedia(type) && isObj(entry) && "schema" in entry);
|
|
143
|
+
if (!media.length)
|
|
144
|
+
return notAdded("no_json_request_body");
|
|
145
|
+
for (const [, entry] of media) {
|
|
146
|
+
const result = extendSchema(doc, entry.schema, props);
|
|
147
|
+
if ("reason" in result)
|
|
148
|
+
return notAdded(result.reason);
|
|
149
|
+
entry.schema = result.schema;
|
|
150
|
+
}
|
|
151
|
+
op.requestBody = copy; // inline copy if it was a shared $ref
|
|
152
|
+
report.agentContext = "body";
|
|
153
|
+
}
|
|
101
154
|
const isJsonMedia = (type) => /^application\/(?:[\w.+-]+\+)?json\b/i.test(type);
|
|
102
155
|
const is2xx = (code) => /^2(\d\d|XX)$/i.test(code);
|
|
103
156
|
function appendSentence(text, sentence, marker) {
|
|
@@ -280,6 +333,8 @@ export function enrichOpenApi(input, options) {
|
|
|
280
333
|
op.responses[code] = response; // inline copy if it was a shared $ref
|
|
281
334
|
opReport.responses[code] = "extended";
|
|
282
335
|
}
|
|
336
|
+
if (options.agentContext)
|
|
337
|
+
addAgentContext(doc, version, item, method, op, options.agentContext, opReport);
|
|
283
338
|
// agentcash-style payment metadata carries its own output schema.
|
|
284
339
|
const info = op["x-payment-info"];
|
|
285
340
|
if (isObj(info) && isObj(info.outputSchema)) {
|
package/dist/reporter.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { AgentContext } from "./context.js";
|
|
1
2
|
export type ForgeEvent = {
|
|
2
3
|
type: "challenge";
|
|
3
4
|
route: string;
|
|
4
5
|
ts: number;
|
|
5
6
|
user_agent?: string;
|
|
7
|
+
agent_context?: AgentContext;
|
|
6
8
|
} | {
|
|
7
9
|
type: "interaction";
|
|
8
10
|
feedback_id: string;
|
|
@@ -13,6 +15,7 @@ export type ForgeEvent = {
|
|
|
13
15
|
network?: string;
|
|
14
16
|
amount?: string;
|
|
15
17
|
user_agent?: string;
|
|
18
|
+
agent_context?: AgentContext;
|
|
16
19
|
ts: number;
|
|
17
20
|
};
|
|
18
21
|
/** Batches events to the backend in the background. Drops the oldest events if the backend stays down. */
|
package/dist/x402.d.ts
CHANGED
|
@@ -1,19 +1,39 @@
|
|
|
1
|
-
|
|
1
|
+
import { type Tone } from "./ask.js";
|
|
2
|
+
/** Key of the Forge extension in x402 v2 `extensions`: in the 402 challenge (next to e.g. `bazaar`) and in the payment receipt. */
|
|
2
3
|
export declare const FEEDBACK_EXTENSION = "forge-feedback";
|
|
3
4
|
/**
|
|
4
5
|
* The `forge-feedback` challenge extension: how to rate the call, as structured data. Clients that inspect
|
|
5
6
|
* the 402 (e.g. `awal x402 details`) print every extension, so this reaches agents before they pay.
|
|
7
|
+
* With `agentContext`, it also says how to report agent context with the paid request.
|
|
6
8
|
*/
|
|
7
|
-
export declare function feedbackExtension(rateUrl: string): {
|
|
9
|
+
export declare function feedbackExtension(rateUrl: string, tone?: Tone, agentContext?: string): {
|
|
8
10
|
info: {
|
|
11
|
+
agent_context?: string | undefined;
|
|
12
|
+
rate: string;
|
|
13
|
+
outcome: string[];
|
|
14
|
+
feedback_id: string;
|
|
15
|
+
payment: string;
|
|
9
16
|
protocol: string;
|
|
10
17
|
ask: string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
/** The receipt extension: the same ask, with the real feedback ID, in the settlement response after payment. */
|
|
21
|
+
export declare function receiptExtension(rateUrl: string, feedbackId: string, tone?: Tone): {
|
|
22
|
+
info: {
|
|
23
|
+
protocol: string;
|
|
24
|
+
ask: string;
|
|
25
|
+
feedback_id: string;
|
|
11
26
|
rate: string;
|
|
12
27
|
outcome: string[];
|
|
13
|
-
feedback_id: string;
|
|
14
28
|
payment: string;
|
|
15
29
|
};
|
|
16
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* Add the receipt extension to a base64 PAYMENT-RESPONSE header (x402 v2 settlement response), only when
|
|
33
|
+
* settlement succeeded. The receipt is informational for the client: facilitator fields are untouched.
|
|
34
|
+
* Returns undefined when the header can't be parsed, settlement failed, or the extension is already there.
|
|
35
|
+
*/
|
|
36
|
+
export declare function describeReceipt(headerValue: string, extension: unknown): string | undefined;
|
|
17
37
|
/** What the SDK adds to a challenge. Each part is skipped when already present. */
|
|
18
38
|
export interface ChallengeAdditions {
|
|
19
39
|
/** Appended to the description (v2 resource.description, v1 accepts[].description). */
|
package/dist/x402.js
CHANGED
|
@@ -1,22 +1,60 @@
|
|
|
1
|
+
import { ASK } from "./ask.js";
|
|
1
2
|
import { PROTOCOL } from "./values.js";
|
|
2
|
-
/** Key of the Forge extension in
|
|
3
|
+
/** Key of the Forge extension in x402 v2 `extensions`: in the 402 challenge (next to e.g. `bazaar`) and in the payment receipt. */
|
|
3
4
|
export const FEEDBACK_EXTENSION = "forge-feedback";
|
|
4
5
|
/**
|
|
5
6
|
* The `forge-feedback` challenge extension: how to rate the call, as structured data. Clients that inspect
|
|
6
7
|
* the 402 (e.g. `awal x402 details`) print every extension, so this reaches agents before they pay.
|
|
8
|
+
* With `agentContext`, it also says how to report agent context with the paid request.
|
|
7
9
|
*/
|
|
8
|
-
export function feedbackExtension(rateUrl) {
|
|
10
|
+
export function feedbackExtension(rateUrl, tone = "soft", agentContext) {
|
|
9
11
|
return {
|
|
10
12
|
info: {
|
|
11
13
|
protocol: PROTOCOL,
|
|
12
|
-
ask:
|
|
14
|
+
ask: ASK[tone].extensionAsk,
|
|
15
|
+
...ASK[tone].extensionExtras,
|
|
13
16
|
rate: `GET ${rateUrl}?feedback_id=FEEDBACK_ID&outcome=fully`,
|
|
14
17
|
outcome: ["fully", "partially", "no"],
|
|
15
18
|
feedback_id: "In the paid response body (feedback_id) and the Forge-Feedback-Id header.",
|
|
16
19
|
payment: "None. Plain GET, not an x402 endpoint.",
|
|
20
|
+
...(agentContext ? { agent_context: agentContext } : {}),
|
|
17
21
|
},
|
|
18
22
|
};
|
|
19
23
|
}
|
|
24
|
+
/** The receipt extension: the same ask, with the real feedback ID, in the settlement response after payment. */
|
|
25
|
+
export function receiptExtension(rateUrl, feedbackId, tone = "soft") {
|
|
26
|
+
return {
|
|
27
|
+
info: {
|
|
28
|
+
protocol: PROTOCOL,
|
|
29
|
+
ask: ASK[tone].extensionAsk,
|
|
30
|
+
feedback_id: feedbackId,
|
|
31
|
+
rate: `GET ${rateUrl}?feedback_id=${feedbackId}&outcome=fully`,
|
|
32
|
+
outcome: ["fully", "partially", "no"],
|
|
33
|
+
payment: "None. Plain GET, not an x402 endpoint.",
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Add the receipt extension to a base64 PAYMENT-RESPONSE header (x402 v2 settlement response), only when
|
|
39
|
+
* settlement succeeded. The receipt is informational for the client: facilitator fields are untouched.
|
|
40
|
+
* Returns undefined when the header can't be parsed, settlement failed, or the extension is already there.
|
|
41
|
+
*/
|
|
42
|
+
export function describeReceipt(headerValue, extension) {
|
|
43
|
+
let receipt;
|
|
44
|
+
try {
|
|
45
|
+
receipt = JSON.parse(Buffer.from(headerValue, "base64").toString("utf8"));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
if (!receipt || typeof receipt !== "object" || receipt.success !== true)
|
|
51
|
+
return undefined;
|
|
52
|
+
const extensions = receipt.extensions;
|
|
53
|
+
if (extensions !== undefined && extensions !== null && (typeof extensions !== "object" || Array.isArray(extensions) || FEEDBACK_EXTENSION in extensions))
|
|
54
|
+
return undefined;
|
|
55
|
+
receipt.extensions = { ...extensions, [FEEDBACK_EXTENSION]: extension };
|
|
56
|
+
return Buffer.from(JSON.stringify(receipt), "utf8").toString("base64");
|
|
57
|
+
}
|
|
20
58
|
function appendSentence(description, sentence, marker) {
|
|
21
59
|
const current = typeof description === "string" ? description.trim() : "";
|
|
22
60
|
return current.includes(marker) ? current : current ? `${current} ${sentence}` : sentence;
|