@forgeintel/sdk 0.5.0-beta.2 → 0.5.0-beta.3
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 +20 -1
- package/dist/context.d.ts +15 -10
- package/dist/context.js +29 -12
- package/dist/core.d.ts +6 -0
- package/dist/core.js +33 -5
- 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/next.js +4 -0
- package/dist/openapi.d.ts +2 -1
- package/dist/openapi.js +11 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -93,7 +93,7 @@ OpenAPI: Swagger 2.0 and OpenAPI 3.0, 3.1 and 3.2. Additive only; shared compone
|
|
|
93
93
|
| `challengeSentence` | built-in | Override for wording experiments. `{rate_url}` and `{summary_url}` are substituted. Wording that presents the rating as required, makes anything depend on it, or asks for user data is refused (with a warning). |
|
|
94
94
|
| `challengeExtension` | `true` | Add the `forge-feedback` extension to x402 v2 challenges. |
|
|
95
95
|
| `receiptExtension` | `true` | Add it, with the real `feedback_id`, to the x402 v2 payment receipt (`PAYMENT-RESPONSE`). |
|
|
96
|
-
| `agentContext` | `true` | Ask for self-reported `agent_context` (agent name, search query), record it, and remove it before your code runs. `{ searchQuery: false }` or `false` to limit. |
|
|
96
|
+
| `agentContext` | `true` | Ask for self-reported `agent_context` (agent name, search query), record it, and remove it before your code runs. `{ searchQuery: false }` or `false` to limit. `{ required: true }` requires context before payment processing. |
|
|
97
97
|
| `injectBody` | `true` | Add `feedback_id` / `feedback_url` to paid JSON bodies. |
|
|
98
98
|
| `rateHint` | built-in | The `rate_this_call` field. A string overrides it (`{feedback_url}` is substituted); `false` removes it. |
|
|
99
99
|
| `injectText` | `false` | Append a two-line trailer to paid `text/plain` bodies. |
|
|
@@ -108,3 +108,22 @@ OpenAPI: Swagger 2.0 and OpenAPI 3.0, 3.1 and 3.2. Additive only; shared compone
|
|
|
108
108
|
Source, examples and design notes: [github.com/ClawCash/forge-feedback](https://github.com/ClawCash/forge-feedback).
|
|
109
109
|
|
|
110
110
|
Client headers are captured automatically at discovery, challenge, payment, and rating stages, including unknown clients. Forge identifies awal, AgentCash, pay.sh and x402scan-mcp from known headers; other clients remain unknown. There is no client-signals switch. Legacy `agent_context.client` inputs remain accepted but are no longer requested.
|
|
111
|
+
|
|
112
|
+
### Requiring agent context
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
const forge = createForge({
|
|
116
|
+
apiKey: process.env.FORGE_API_KEY!,
|
|
117
|
+
backendUrl: "https://your-forge-backend.example/api/sdk/v2",
|
|
118
|
+
publicUrl: "https://api.example.com",
|
|
119
|
+
agentContext: { required: true },
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`agentContext: true` remains optional; `false` disables asking and recording. Required mode documents `agent_context` and its `agent_type` and `search_query` fields as required. Use a listed agent name (or `Others` with `agent_type_other`), and the search query or `"direct"`. `{ required: true, searchQuery: false }` requires only the agent type. Context is self-reported, not authenticated identity.
|
|
124
|
+
|
|
125
|
+
Mount Forge **before payment middleware**. A request carrying `PAYMENT-SIGNATURE` or `X-PAYMENT` gets HTTP 400 with field errors when context is missing or invalid, before the downstream payment handler runs. Initial unpaid requests can still receive a 402, and inspection and feedback routes stay accessible. Feedback remains optional and has its own `feedback` switch.
|
|
126
|
+
|
|
127
|
+
Required mode reads JSON bodies up to 1 MiB, including chunked bodies, before payment processing. It rejects malformed or oversized JSON. Express parses these bodies even if your JSON parser is mounted later; mount a custom parser before Forge if you need one. For non-JSON bodies and bodyless requests, supply `agent_type` and `agent_search_query` query parameters. Forge strips context before forwarding to merchant validators. Next.js integrations that use a payment proxy should wrap both the proxy with `forge.proxy(...)` and the route with `forge.withForge(...)`.
|
|
128
|
+
|
|
129
|
+
If using the framework-free core directly, call `requestUrl` and `requestBody`, then check `call.contextError()` **before** invoking your payment logic; send its response when non-null. Existing SDK integrations remain optional until explicitly configured and upgraded.
|
package/dist/context.d.ts
CHANGED
|
@@ -31,17 +31,18 @@ export declare function takeFromUrl(url: string): {
|
|
|
31
31
|
url: string;
|
|
32
32
|
raw?: Record<string, string>;
|
|
33
33
|
};
|
|
34
|
-
|
|
35
|
-
export declare function agentContextSchema({ searchQuery }?: {
|
|
34
|
+
export interface ContextOptions {
|
|
36
35
|
searchQuery?: boolean;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
required?: boolean;
|
|
37
|
+
}
|
|
38
|
+
/** JSON Schema for the `agent_context` body property. */
|
|
39
|
+
export declare function agentContextSchema({ searchQuery, required }?: ContextOptions): {
|
|
40
40
|
properties: {
|
|
41
41
|
search_query?: {
|
|
42
|
+
description: string;
|
|
43
|
+
minLength?: number | undefined;
|
|
42
44
|
type: string;
|
|
43
45
|
maxLength: number;
|
|
44
|
-
description: string;
|
|
45
46
|
} | undefined;
|
|
46
47
|
agent_type: {
|
|
47
48
|
type: string;
|
|
@@ -49,11 +50,15 @@ export declare function agentContextSchema({ searchQuery }?: {
|
|
|
49
50
|
description: string;
|
|
50
51
|
};
|
|
51
52
|
agent_type_other: {
|
|
53
|
+
description: string;
|
|
54
|
+
minLength?: number | undefined;
|
|
52
55
|
type: string;
|
|
53
56
|
maxLength: number;
|
|
54
|
-
description: string;
|
|
55
57
|
};
|
|
56
58
|
};
|
|
59
|
+
required?: string[] | undefined;
|
|
60
|
+
type: string;
|
|
61
|
+
description: string;
|
|
57
62
|
};
|
|
58
63
|
/** The optional query parameters, for operations without a request body. */
|
|
59
64
|
export declare function agentContextParameters({ searchQuery }?: {
|
|
@@ -64,6 +69,6 @@ export declare function agentContextParameters({ searchQuery }?: {
|
|
|
64
69
|
schema: Record<string, unknown>;
|
|
65
70
|
}[];
|
|
66
71
|
/** One line for the forge-feedback extension, so clients that only inspect the 402 learn about it too. */
|
|
67
|
-
export declare function agentContextAsk({ searchQuery }?:
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
export declare function agentContextAsk({ searchQuery, required }?: ContextOptions): string;
|
|
73
|
+
/** Validate the original values, before lenient parsing can truncate or discard them. */
|
|
74
|
+
export declare function contextIssues(raw: Record<string, unknown>, { searchQuery }: ContextOptions): string[];
|
package/dist/context.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// API contract never changes. Never required: a required field breaks strict validators and makes
|
|
4
|
-
// agents invent answers.
|
|
1
|
+
// Self-reported facts about the calling agent, removed before merchant validation.
|
|
2
|
+
// Optional by default; merchants can require context on payment-bearing requests.
|
|
5
3
|
/** Suggested agent names; anything else is kept as given. */
|
|
6
4
|
export const AGENT_TYPES = ["Claude Code", "Codex", "Cursor", "Grok Bot", "Muse", "Hermes", "Instinct", "OpenClaw", "Others"];
|
|
7
5
|
/** Suggested x402 clients; anything else is kept as given. */
|
|
@@ -92,15 +90,16 @@ const DESCRIPTIONS = {
|
|
|
92
90
|
agent_type_other: "Your agent's name, when agent_type is Others.",
|
|
93
91
|
search_query: "The search query you used to find this service, or \"direct\" if you did not search.",
|
|
94
92
|
};
|
|
95
|
-
/** JSON Schema for the
|
|
96
|
-
export function agentContextSchema({ searchQuery = true } = {}) {
|
|
93
|
+
/** JSON Schema for the `agent_context` body property. */
|
|
94
|
+
export function agentContextSchema({ searchQuery = true, required = false } = {}) {
|
|
97
95
|
return {
|
|
98
96
|
type: "object",
|
|
99
97
|
description: DESCRIPTIONS.object,
|
|
98
|
+
...(required ? { required: ["agent_type", ...(searchQuery ? ["search_query"] : [])] } : {}),
|
|
100
99
|
properties: {
|
|
101
100
|
agent_type: { type: "string", enum: [...AGENT_TYPES], description: DESCRIPTIONS.agent_type },
|
|
102
|
-
agent_type_other: { type: "string", maxLength: LIMITS.agent_type_other, description: DESCRIPTIONS.agent_type_other },
|
|
103
|
-
...(searchQuery ? { search_query: { type: "string", maxLength: LIMITS.search_query, description: DESCRIPTIONS.search_query } } : {}),
|
|
101
|
+
agent_type_other: { type: "string", maxLength: LIMITS.agent_type_other, ...(required ? { minLength: 1 } : {}), description: DESCRIPTIONS.agent_type_other },
|
|
102
|
+
...(searchQuery ? { search_query: { type: "string", maxLength: LIMITS.search_query, ...(required ? { minLength: 1 } : {}), description: DESCRIPTIONS.search_query } } : {}),
|
|
104
103
|
},
|
|
105
104
|
};
|
|
106
105
|
}
|
|
@@ -115,8 +114,26 @@ export function agentContextParameters({ searchQuery = true } = {}) {
|
|
|
115
114
|
return params;
|
|
116
115
|
}
|
|
117
116
|
/** One line for the forge-feedback extension, so clients that only inspect the 402 learn about it too. */
|
|
118
|
-
export function agentContextAsk({ searchQuery = true } = {}) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
117
|
+
export function agentContextAsk({ searchQuery = true, required = false } = {}) {
|
|
118
|
+
const fields = searchQuery ? "agent_type, search_query" : "agent_type";
|
|
119
|
+
const query = searchQuery ? "agent_type, agent_search_query query parameters" : "agent_type query parameter";
|
|
120
|
+
return `${required ? "Required before payment" : "Please add"}: agent_context {${fields}} in your paid request's JSON body (without a body: ${query}).${searchQuery ? ' search_query: share the query you used to find this service, or "direct" if you did not search.' : ""}`;
|
|
121
|
+
}
|
|
122
|
+
/** Validate the original values, before lenient parsing can truncate or discard them. */
|
|
123
|
+
export function contextIssues(raw, { searchQuery = true }) {
|
|
124
|
+
const issues = [];
|
|
125
|
+
for (const key of ["agent_type", ...(searchQuery ? ["search_query"] : [])]) {
|
|
126
|
+
const value = raw[key];
|
|
127
|
+
if (typeof value !== "string" || !value.trim() || value.length > LIMITS[key] || /[\u0000-\u001f\u007f]/.test(value)) {
|
|
128
|
+
issues.push(`${key} must be a non-empty string of at most ${LIMITS[key]} characters`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const parsed = parseAgentContext(raw);
|
|
132
|
+
if (parsed?.agent_type && !AGENT_TYPES.includes(parsed.agent_type)) {
|
|
133
|
+
issues.push("agent_type must be a listed agent name, or Others with agent_type_other");
|
|
134
|
+
}
|
|
135
|
+
if (raw.agent_type_other !== undefined && (typeof raw.agent_type_other !== "string" || !raw.agent_type_other.trim() || raw.agent_type_other.length > 80)) {
|
|
136
|
+
issues.push("agent_type_other must be a non-empty string of at most 80 characters");
|
|
137
|
+
}
|
|
138
|
+
return issues;
|
|
122
139
|
}
|
package/dist/core.d.ts
CHANGED
|
@@ -55,9 +55,11 @@ export interface ForgeOptions {
|
|
|
55
55
|
* extension and OpenAPI, reads it, reports it with the call, and removes it before your validators and handlers
|
|
56
56
|
* run. Default true. `{ searchQuery: false }` stops asking for (and recording) the search query; `false` stops
|
|
57
57
|
* asking and recording altogether (the fields are still removed if an agent sends them).
|
|
58
|
+
* `{ required: true }` rejects payment-bearing requests with missing/invalid context before payment middleware.
|
|
58
59
|
*/
|
|
59
60
|
agentContext?: boolean | {
|
|
60
61
|
searchQuery?: boolean;
|
|
62
|
+
required?: boolean;
|
|
61
63
|
};
|
|
62
64
|
/** Add feedback_id and feedback_url to JSON object bodies of paid responses. Default true. */
|
|
63
65
|
injectBody?: boolean;
|
|
@@ -106,6 +108,10 @@ export interface ForgeResponse {
|
|
|
106
108
|
export interface ForgeCall {
|
|
107
109
|
/** Set when the request carried a payment header (x402 v2 PAYMENT-SIGNATURE or v1 X-PAYMENT). */
|
|
108
110
|
readonly feedbackId: string | undefined;
|
|
111
|
+
/** Whether this payment-bearing request must provide agent context. */
|
|
112
|
+
readonly contextRequired: boolean;
|
|
113
|
+
/** Call after requestUrl/requestBody and BEFORE payment processing. Null means context is acceptable. */
|
|
114
|
+
contextError(): ForgeResponse | null;
|
|
109
115
|
/** A JSON body about to be sent: 402 challenges get the rating ask, paid 2xx objects get the feedback fields. */
|
|
110
116
|
json(status: number, body: unknown): unknown;
|
|
111
117
|
/** A text body about to be sent: gets the two-line trailer on paid 2xx text/plain when injectText is on. */
|
package/dist/core.js
CHANGED
|
@@ -4,7 +4,7 @@ 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";
|
|
@@ -61,7 +61,15 @@ export function checkOptions(input) {
|
|
|
61
61
|
fallback("tone", TONES.includes(raw.tone), `must be one of ${TONES.map((t) => `"${t}"`).join(", ")}`);
|
|
62
62
|
fallback("challengeSentence", typeof raw.challengeSentence === "string" && raw.challengeSentence.trim() !== "", "must be a non-empty string");
|
|
63
63
|
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 }");
|
|
64
|
+
fallback("agentContext", boolean(raw.agentContext) || (!!raw.agentContext && typeof raw.agentContext === "object" && !Array.isArray(raw.agentContext)), "must be true, false or { searchQuery, required }");
|
|
65
|
+
if (raw.agentContext && typeof raw.agentContext === "object" && !Array.isArray(raw.agentContext)) {
|
|
66
|
+
const context = raw.agentContext;
|
|
67
|
+
for (const key of ["required", "searchQuery"]) {
|
|
68
|
+
if (context[key] !== undefined && typeof context[key] !== "boolean") {
|
|
69
|
+
errors.push(`agentContext.${key} must be a boolean`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
65
73
|
for (const key of ["feedback", "describeChallenges", "challengeExtension", "receiptExtension", "injectBody", "injectText", "strict"])
|
|
66
74
|
fallback(key, boolean(raw[key]), "must be true or false");
|
|
67
75
|
// The lines no wording may cross, whatever the merchant configures (see ask.ts).
|
|
@@ -97,6 +105,8 @@ export function checkOptions(input) {
|
|
|
97
105
|
function disabledCore(errors, warnings) {
|
|
98
106
|
const passThrough = {
|
|
99
107
|
feedbackId: undefined,
|
|
108
|
+
contextRequired: false,
|
|
109
|
+
contextError: () => null,
|
|
100
110
|
json: (_status, body) => body,
|
|
101
111
|
text: (_status, _type, body) => body,
|
|
102
112
|
headers: () => ({}),
|
|
@@ -165,6 +175,7 @@ function enabledCore(options, configWarnings) {
|
|
|
165
175
|
const contextOption = options.agentContext ?? true;
|
|
166
176
|
const collectContext = contextOption !== false;
|
|
167
177
|
const searchQuery = collectContext && (typeof contextOption !== "object" || contextOption.searchQuery !== false);
|
|
178
|
+
const requiredContext = collectContext && typeof contextOption === "object" && contextOption.required === true;
|
|
168
179
|
const receipts = options.receiptExtension ?? true;
|
|
169
180
|
const rateHint = options.rateHint === false
|
|
170
181
|
? null
|
|
@@ -194,10 +205,10 @@ function enabledCore(options, configWarnings) {
|
|
|
194
205
|
const challengeMarker = challengeSentence.includes(rateUrl) ? rateUrl : challengeSentence;
|
|
195
206
|
const challengeAdditions = {
|
|
196
207
|
...(describe ? { sentence: challengeSentence, marker: challengeMarker } : {}),
|
|
197
|
-
...(!feedback || options.challengeExtension === false ? {} : { extension: feedbackExtension(rateUrl, tone, collectContext ? agentContextAsk({ searchQuery }) : undefined) }),
|
|
208
|
+
...(!feedback || options.challengeExtension === false ? {} : { extension: feedbackExtension(rateUrl, tone, collectContext ? agentContextAsk({ searchQuery, required: requiredContext }) : undefined) }),
|
|
198
209
|
};
|
|
199
210
|
if (!feedback && collectContext)
|
|
200
|
-
challengeAdditions.contextExtension = { info: { agent_context: agentContextAsk({ searchQuery }), optional:
|
|
211
|
+
challengeAdditions.contextExtension = { info: { agent_context: agentContextAsk({ searchQuery, required: requiredContext }), optional: !requiredContext } };
|
|
201
212
|
const touchChallenges = Boolean(challengeAdditions.sentence || challengeAdditions.extension || challengeAdditions.contextExtension);
|
|
202
213
|
// Set once a document has been enriched; lets body injection respect strict response schemas.
|
|
203
214
|
let allowInjection = null;
|
|
@@ -212,7 +223,7 @@ function enabledCore(options, configWarnings) {
|
|
|
212
223
|
isPaidOperation: openapi?.isPaidOperation,
|
|
213
224
|
describeOperations: openapi?.describeOperations,
|
|
214
225
|
hintField: Boolean(rateHint),
|
|
215
|
-
agentContext: collectContext ? { searchQuery } : undefined,
|
|
226
|
+
agentContext: collectContext ? { searchQuery, required: requiredContext } : undefined,
|
|
216
227
|
});
|
|
217
228
|
stats.openapi = result.report;
|
|
218
229
|
if (result.report.enriched)
|
|
@@ -342,6 +353,8 @@ function enabledCore(options, configWarnings) {
|
|
|
342
353
|
}
|
|
343
354
|
const passThrough = {
|
|
344
355
|
feedbackId: undefined,
|
|
356
|
+
contextRequired: false,
|
|
357
|
+
contextError: () => null,
|
|
345
358
|
json: (_status, body) => body,
|
|
346
359
|
text: (_status, _type, body) => body,
|
|
347
360
|
headers: () => ({}),
|
|
@@ -372,10 +385,13 @@ function enabledCore(options, configWarnings) {
|
|
|
372
385
|
let bodyChallenge = false;
|
|
373
386
|
let headerChallenge = false;
|
|
374
387
|
let context;
|
|
388
|
+
let rawContext = {};
|
|
375
389
|
let receiptFacts = {};
|
|
376
390
|
const remember = (raw) => {
|
|
377
391
|
if (!collectContext)
|
|
378
392
|
return;
|
|
393
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw))
|
|
394
|
+
rawContext = { ...rawContext, ...raw };
|
|
379
395
|
const parsed = parseAgentContext(raw, { searchQuery });
|
|
380
396
|
if (parsed)
|
|
381
397
|
context = { ...context, ...parsed };
|
|
@@ -383,6 +399,18 @@ function enabledCore(options, configWarnings) {
|
|
|
383
399
|
const ok = (status) => status >= 200 && status < 300;
|
|
384
400
|
const handle = {
|
|
385
401
|
feedbackId,
|
|
402
|
+
contextRequired: requiredContext && !!paymentHeader,
|
|
403
|
+
contextError() {
|
|
404
|
+
if (!handle.contextRequired)
|
|
405
|
+
return null;
|
|
406
|
+
const issues = contextIssues(rawContext, { searchQuery });
|
|
407
|
+
return issues.length ? {
|
|
408
|
+
status: 400, headers: {}, body: {
|
|
409
|
+
error: "agent_context_required", issues,
|
|
410
|
+
message: agentContextAsk({ searchQuery, required: true }),
|
|
411
|
+
},
|
|
412
|
+
} : null;
|
|
413
|
+
},
|
|
386
414
|
json(status, body) {
|
|
387
415
|
try {
|
|
388
416
|
if (status === 402) {
|
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
|
+
// Opt-in only: 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; optional mode passes larger bodies through, required mode rejects oversized JSON. */
|
|
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 || typeof options.agentContext !== "object" || !options.agentContext.required)
|
|
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/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)
|
package/dist/openapi.d.ts
CHANGED
|
@@ -18,9 +18,10 @@ export interface EnrichOptions {
|
|
|
18
18
|
describeOperations?: boolean;
|
|
19
19
|
/** Also document the optional rate_this_call body field (when the SDK's rateHint is on). */
|
|
20
20
|
hintField?: boolean;
|
|
21
|
-
/** Document
|
|
21
|
+
/** Document agent context on paid operations: `agent_context` in JSON request bodies, agent_* query parameters otherwise. */
|
|
22
22
|
agentContext?: {
|
|
23
23
|
searchQuery: boolean;
|
|
24
|
+
required?: boolean;
|
|
24
25
|
};
|
|
25
26
|
}
|
|
26
27
|
export interface OperationReport {
|
package/dist/openapi.js
CHANGED
|
@@ -100,7 +100,7 @@ function extendSchema(doc, schema, props) {
|
|
|
100
100
|
return { schema: copy };
|
|
101
101
|
}
|
|
102
102
|
const BODYLESS = new Set(["get", "head", "delete", "options", "trace"]);
|
|
103
|
-
/** Document
|
|
103
|
+
/** Document agent context on a paid operation; skip schemas that cannot be extended safely. */
|
|
104
104
|
function addAgentContext(doc, version, item, method, op, opts, report) {
|
|
105
105
|
const resolve = (v) => (isObj(v) && typeof v.$ref === "string" ? resolveRef(doc, v.$ref) : v);
|
|
106
106
|
const listed = [...(Array.isArray(item.parameters) ? item.parameters : []), ...(Array.isArray(op.parameters) ? op.parameters : [])].map(resolve).filter(isObj);
|
|
@@ -115,8 +115,8 @@ function addAgentContext(doc, version, item, method, op, opts, report) {
|
|
|
115
115
|
if (!params.length)
|
|
116
116
|
return notAdded("parameter_collision");
|
|
117
117
|
const documented = params.map((p) => version === "2.0"
|
|
118
|
-
? { name: p.name, in: "query", required:
|
|
119
|
-
: { name: p.name, in: "query", required:
|
|
118
|
+
? { name: p.name, in: "query", required: !!opts.required && p.name !== "agent_type_other", description: p.description, ...p.schema }
|
|
119
|
+
: { name: p.name, in: "query", required: !!opts.required && p.name !== "agent_type_other", description: p.description, schema: p.schema });
|
|
120
120
|
op.parameters = [...(Array.isArray(op.parameters) ? op.parameters : []), ...documented];
|
|
121
121
|
report.agentContext = "query";
|
|
122
122
|
return;
|
|
@@ -130,6 +130,10 @@ function addAgentContext(doc, version, item, method, op, opts, report) {
|
|
|
130
130
|
const result = extendSchema(doc, param.schema, props);
|
|
131
131
|
if ("reason" in result)
|
|
132
132
|
return notAdded(result.reason);
|
|
133
|
+
if (opts.required) {
|
|
134
|
+
result.schema.required = [...new Set([...(Array.isArray(result.schema.required) ? result.schema.required : []), CONTEXT_FIELD])];
|
|
135
|
+
param.required = true;
|
|
136
|
+
}
|
|
133
137
|
param.schema = result.schema;
|
|
134
138
|
op.parameters = params.map((p, i) => (i === index ? param : p)); // inline copy if it was a shared $ref
|
|
135
139
|
report.agentContext = "body";
|
|
@@ -146,8 +150,12 @@ function addAgentContext(doc, version, item, method, op, opts, report) {
|
|
|
146
150
|
const result = extendSchema(doc, entry.schema, props);
|
|
147
151
|
if ("reason" in result)
|
|
148
152
|
return notAdded(result.reason);
|
|
153
|
+
if (opts.required)
|
|
154
|
+
result.schema.required = [...new Set([...(Array.isArray(result.schema.required) ? result.schema.required : []), CONTEXT_FIELD])];
|
|
149
155
|
entry.schema = result.schema;
|
|
150
156
|
}
|
|
157
|
+
if (opts.required)
|
|
158
|
+
copy.required = true;
|
|
151
159
|
op.requestBody = copy; // inline copy if it was a shared $ref
|
|
152
160
|
report.agentContext = "body";
|
|
153
161
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgeintel/sdk",
|
|
3
|
-
"version": "0.5.0-beta.
|
|
3
|
+
"version": "0.5.0-beta.3",
|
|
4
4
|
"description": "The Forge SDK for x402 paid APIs: agent feedback (feedback IDs, one-request GET ratings), agent context, OpenAPI and challenge enrichment, and passive call signals. Express, Hono, Next.js and any fetch handler. Never on your critical path.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|