@forgeintel/sdk 0.2.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/LICENSE +21 -0
- package/README.md +75 -0
- package/dist/core.d.ts +147 -0
- package/dist/core.js +442 -0
- package/dist/express.d.ts +13 -0
- package/dist/express.js +182 -0
- package/dist/id.d.ts +14 -0
- package/dist/id.js +32 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +6 -0
- package/dist/openapi.d.ts +44 -0
- package/dist/openapi.js +357 -0
- package/dist/reporter.d.ts +34 -0
- package/dist/reporter.js +59 -0
- package/dist/values.d.ts +35 -0
- package/dist/values.js +46 -0
- package/dist/x402.d.ts +45 -0
- package/dist/x402.js +113 -0
- package/package.json +81 -0
package/dist/id.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
// 16 bytes -> 22 base64url chars: [0-3] issued-at seconds, [4-9] random, [10-15] HMAC tag.
|
|
3
|
+
export const FEEDBACK_ID_PATTERN = /^[A-Za-z0-9_-]{22}$/;
|
|
4
|
+
export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
const HEAD_BYTES = 10;
|
|
6
|
+
const TAG_BYTES = 6;
|
|
7
|
+
const CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
8
|
+
function tag(key, head) {
|
|
9
|
+
return createHmac("sha256", key).update(head).digest().subarray(0, TAG_BYTES);
|
|
10
|
+
}
|
|
11
|
+
export function mintFeedbackId(key, now = Date.now()) {
|
|
12
|
+
const head = Buffer.alloc(HEAD_BYTES);
|
|
13
|
+
head.writeUInt32BE(Math.floor(now / 1000) >>> 0, 0);
|
|
14
|
+
randomBytes(HEAD_BYTES - 4).copy(head, 4);
|
|
15
|
+
return Buffer.concat([head, tag(key, head)]).toString("base64url");
|
|
16
|
+
}
|
|
17
|
+
export function verifyFeedbackId(key, id, { ttlMs = DEFAULT_TTL_MS, now = Date.now() } = {}) {
|
|
18
|
+
if (typeof id !== "string" || !FEEDBACK_ID_PATTERN.test(id))
|
|
19
|
+
return { valid: false, reason: "malformed" };
|
|
20
|
+
const raw = Buffer.from(id, "base64url");
|
|
21
|
+
// Reject non-canonical encodings so one ID can't be submitted under several spellings.
|
|
22
|
+
if (raw.length !== HEAD_BYTES + TAG_BYTES || raw.toString("base64url") !== id) {
|
|
23
|
+
return { valid: false, reason: "malformed" };
|
|
24
|
+
}
|
|
25
|
+
const head = raw.subarray(0, HEAD_BYTES);
|
|
26
|
+
if (!timingSafeEqual(raw.subarray(HEAD_BYTES), tag(key, head)))
|
|
27
|
+
return { valid: false, reason: "bad_signature" };
|
|
28
|
+
const issuedAt = head.readUInt32BE(0) * 1000;
|
|
29
|
+
if (issuedAt > now + CLOCK_SKEW_MS || now - issuedAt > ttlMs)
|
|
30
|
+
return { valid: false, reason: "expired" };
|
|
31
|
+
return { valid: true, issuedAt };
|
|
32
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { createForge, createForgeFeedback } from "./express.js";
|
|
2
|
+
export type { Forge, ForgeFeedback } from "./express.js";
|
|
3
|
+
export { createForgeCore, checkOptions, BODY_LIMIT, SPEC_LIMIT } from "./core.js";
|
|
4
|
+
export type { ForgeCall, ForgeCore, ForgeDiagnostics, ForgeFeedbackOptions, ForgeOptions, ForgeRequest, ForgeResponse, OpenApiOptions } from "./core.js";
|
|
5
|
+
export { enrichOpenApi, detectVersion } from "./openapi.js";
|
|
6
|
+
export type { EnrichOptions, EnrichReport, OperationReport, ResponseSupport, SpecVersion } from "./openapi.js";
|
|
7
|
+
export { mintFeedbackId, verifyFeedbackId, FEEDBACK_ID_PATTERN, DEFAULT_TTL_MS } from "./id.js";
|
|
8
|
+
export type { FeedbackIdCheck } from "./id.js";
|
|
9
|
+
export { OUTCOMES, ISSUES, NOTE_MAX_LENGTH, PROTOCOL, parseSubmission } from "./values.js";
|
|
10
|
+
export type { Outcome, Issue, Submission } from "./values.js";
|
|
11
|
+
export { describeChallenge, describeChallengeBody, feedbackExtension, FEEDBACK_EXTENSION, readPaymentHeader } from "./x402.js";
|
|
12
|
+
export type { ChallengeAdditions } from "./x402.js";
|
|
13
|
+
export type { ForgeEvent } from "./reporter.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createForge, createForgeFeedback } from "./express.js";
|
|
2
|
+
export { createForgeCore, checkOptions, BODY_LIMIT, SPEC_LIMIT } from "./core.js";
|
|
3
|
+
export { enrichOpenApi, detectVersion } from "./openapi.js";
|
|
4
|
+
export { mintFeedbackId, verifyFeedbackId, FEEDBACK_ID_PATTERN, DEFAULT_TTL_MS } from "./id.js";
|
|
5
|
+
export { OUTCOMES, ISSUES, NOTE_MAX_LENGTH, PROTOCOL, parseSubmission } from "./values.js";
|
|
6
|
+
export { describeChallenge, describeChallengeBody, feedbackExtension, FEEDBACK_EXTENSION, readPaymentHeader } from "./x402.js";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
type Json = Record<string, any>;
|
|
2
|
+
export type SpecVersion = "2.0" | "3.0" | "3.1" | "3.2";
|
|
3
|
+
export type ResponseSupport = "extended" | "incompatible" | "undocumented";
|
|
4
|
+
export interface EnrichOptions {
|
|
5
|
+
/** Merchant public origin, e.g. https://api.example.com */
|
|
6
|
+
publicUrl: string;
|
|
7
|
+
/** Public path of the feedback routes, e.g. /feedback */
|
|
8
|
+
basePath: string;
|
|
9
|
+
/** Sentence appended to x-guidance and paid operation descriptions. */
|
|
10
|
+
sentence: string;
|
|
11
|
+
/** Substring that marks the sentence as already present. */
|
|
12
|
+
marker: string;
|
|
13
|
+
/** Override paid-operation detection. Default: the operation declares a 402 response or x-payment-info. */
|
|
14
|
+
isPaidOperation?: (method: string, path: string, operation: Json) => boolean;
|
|
15
|
+
/** Append the sentence to paid operation descriptions. Default true. */
|
|
16
|
+
describeOperations?: boolean;
|
|
17
|
+
/** Also document the optional rate_this_call body field (when the SDK's rateHint is on). */
|
|
18
|
+
hintField?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface OperationReport {
|
|
21
|
+
method: string;
|
|
22
|
+
/** Path as written in the document (relative to the server URL). */
|
|
23
|
+
path: string;
|
|
24
|
+
/** Per 2xx status code: whether feedback_id was added to its JSON schema. */
|
|
25
|
+
responses: Record<string, ResponseSupport>;
|
|
26
|
+
reasons: string[];
|
|
27
|
+
}
|
|
28
|
+
export interface EnrichReport {
|
|
29
|
+
version: SpecVersion | null;
|
|
30
|
+
enriched: boolean;
|
|
31
|
+
/** Path prefix from servers[0] / basePath that document paths are relative to. */
|
|
32
|
+
serverPrefix: string;
|
|
33
|
+
feedbackPaths: "added" | "conflict" | "skipped";
|
|
34
|
+
operations: OperationReport[];
|
|
35
|
+
warnings: string[];
|
|
36
|
+
}
|
|
37
|
+
export declare function detectVersion(doc: unknown): SpecVersion | null;
|
|
38
|
+
export declare function enrichOpenApi(input: unknown, options: EnrichOptions): {
|
|
39
|
+
document: unknown;
|
|
40
|
+
report: EnrichReport;
|
|
41
|
+
};
|
|
42
|
+
/** Runtime lookup: may feedback fields be added to this response body without contradicting the document? */
|
|
43
|
+
export declare function createOperationIndex(report: EnrichReport): (method: string, path: string, status: number) => boolean;
|
|
44
|
+
export {};
|
package/dist/openapi.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// Additive, idempotent OpenAPI enrichment for Swagger 2.0 and OpenAPI 3.0 / 3.1 / 3.2.
|
|
2
|
+
// Rules: never mutate the input, never modify a shared $ref target, never override merchant paths,
|
|
3
|
+
// and on any unexpected input return the original document unchanged.
|
|
4
|
+
import { FEEDBACK_ID_PATTERN } from "./id.js";
|
|
5
|
+
import { ISSUES, NOTE_MAX_LENGTH, OUTCOMES, PROTOCOL } from "./values.js";
|
|
6
|
+
const METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
|
|
7
|
+
const ANNOTATIONS = new Set(["$ref", "description", "title", "summary", "example", "examples", "deprecated", "readOnly", "writeOnly", "$comment"]);
|
|
8
|
+
const COMPOSITION = ["oneOf", "anyOf", "not", "if", "then", "else", "$dynamicRef", "discriminator"];
|
|
9
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
10
|
+
const clone = (v) => structuredClone(v);
|
|
11
|
+
export function detectVersion(doc) {
|
|
12
|
+
if (!isObj(doc))
|
|
13
|
+
return null;
|
|
14
|
+
if (doc.swagger === "2.0")
|
|
15
|
+
return "2.0";
|
|
16
|
+
const match = typeof doc.openapi === "string" && /^3\.([0-2])\.\d+/.exec(doc.openapi);
|
|
17
|
+
return match ? `3.${match[1]}` : null;
|
|
18
|
+
}
|
|
19
|
+
function resolveRef(doc, ref) {
|
|
20
|
+
let current = doc;
|
|
21
|
+
let target = ref;
|
|
22
|
+
for (let hops = 0; hops < 16; hops++) {
|
|
23
|
+
if (!target.startsWith("#/"))
|
|
24
|
+
return undefined;
|
|
25
|
+
current = doc;
|
|
26
|
+
for (const raw of target.slice(2).split("/")) {
|
|
27
|
+
const key = decodeURIComponent(raw).replace(/~1/g, "/").replace(/~0/g, "~");
|
|
28
|
+
current = isObj(current) || Array.isArray(current) ? current[key] : undefined;
|
|
29
|
+
}
|
|
30
|
+
if (!isObj(current))
|
|
31
|
+
return undefined;
|
|
32
|
+
if (typeof current.$ref !== "string")
|
|
33
|
+
return current;
|
|
34
|
+
target = current.$ref;
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
function feedbackProperties(rateUrl, hintField = false) {
|
|
39
|
+
return {
|
|
40
|
+
...(hintField ? { rate_this_call: { type: "string", description: "How to rate this paid call in one free request." } } : {}),
|
|
41
|
+
feedback_id: {
|
|
42
|
+
type: "string",
|
|
43
|
+
pattern: FEEDBACK_ID_PATTERN.source,
|
|
44
|
+
description: "Rating ID for this paid call (also sent as the Forge-Feedback-Id header). See x-forge-feedback.",
|
|
45
|
+
},
|
|
46
|
+
feedback_url: {
|
|
47
|
+
type: "string",
|
|
48
|
+
format: "uri",
|
|
49
|
+
description: `Quick-rating URL with outcome left blank; outcome values: ${Object.keys(OUTCOMES).join(", ")}. Base: ${rateUrl}`,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** Returns an extended copy of an object schema, or a reason it can't be extended safely. */
|
|
54
|
+
function extendSchema(doc, schema, props) {
|
|
55
|
+
if (!isObj(schema))
|
|
56
|
+
return { reason: "no_schema" };
|
|
57
|
+
let target = schema;
|
|
58
|
+
let siblings = {};
|
|
59
|
+
if (typeof schema.$ref === "string") {
|
|
60
|
+
if (!schema.$ref.startsWith("#/"))
|
|
61
|
+
return { reason: "external_ref" };
|
|
62
|
+
const extra = Object.keys(schema).filter((k) => !ANNOTATIONS.has(k));
|
|
63
|
+
if (extra.length)
|
|
64
|
+
return { reason: "ref_with_constraints" };
|
|
65
|
+
const resolved = resolveRef(doc, schema.$ref);
|
|
66
|
+
if (!resolved)
|
|
67
|
+
return { reason: "unresolvable_ref" };
|
|
68
|
+
const { $ref: _ref, ...rest } = schema;
|
|
69
|
+
target = resolved;
|
|
70
|
+
siblings = rest;
|
|
71
|
+
}
|
|
72
|
+
// Copy the resolved schema inline so shared components stay untouched.
|
|
73
|
+
const copy = { ...clone(target), ...clone(siblings) };
|
|
74
|
+
if (COMPOSITION.some((k) => k in copy))
|
|
75
|
+
return { reason: "composition" };
|
|
76
|
+
const types = Array.isArray(copy.type) ? copy.type : [copy.type];
|
|
77
|
+
const objectish = types.includes("object") || (copy.type === undefined && (isObj(copy.properties) || Array.isArray(copy.allOf)));
|
|
78
|
+
if (!objectish)
|
|
79
|
+
return { reason: "not_object" };
|
|
80
|
+
if ("propertyNames" in copy || "maxProperties" in copy)
|
|
81
|
+
return { reason: "property_constraints" };
|
|
82
|
+
if (isObj(copy.properties) && Object.keys(props).some((k) => k in copy.properties)) {
|
|
83
|
+
return { reason: "field_collision" };
|
|
84
|
+
}
|
|
85
|
+
if (Array.isArray(copy.allOf)) {
|
|
86
|
+
for (const member of copy.allOf) {
|
|
87
|
+
const m = isObj(member) && typeof member.$ref === "string" ? resolveRef(doc, member.$ref) : member;
|
|
88
|
+
if (!isObj(m))
|
|
89
|
+
return { reason: "unresolvable_ref" };
|
|
90
|
+
// A strict allOf member would reject properties it doesn't declare itself.
|
|
91
|
+
if (m.additionalProperties === false || m.unevaluatedProperties === false || COMPOSITION.some((k) => k in m) || Array.isArray(m.allOf)) {
|
|
92
|
+
return { reason: "strict_composition" };
|
|
93
|
+
}
|
|
94
|
+
if (isObj(m.properties) && Object.keys(props).some((k) => k in m.properties))
|
|
95
|
+
return { reason: "field_collision" };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
copy.properties = { ...(isObj(copy.properties) ? copy.properties : {}), ...clone(props) };
|
|
99
|
+
return { schema: copy };
|
|
100
|
+
}
|
|
101
|
+
const isJsonMedia = (type) => /^application\/(?:[\w.+-]+\+)?json\b/i.test(type);
|
|
102
|
+
const is2xx = (code) => /^2(\d\d|XX)$/i.test(code);
|
|
103
|
+
function appendSentence(text, sentence, marker) {
|
|
104
|
+
const current = typeof text === "string" ? text.trimEnd() : "";
|
|
105
|
+
if (current.includes(marker))
|
|
106
|
+
return current;
|
|
107
|
+
return current ? `${current}${current.includes("\n") ? "\n\n" : " "}${sentence}` : sentence;
|
|
108
|
+
}
|
|
109
|
+
/** Work out the path prefix document paths are relative to, and whether it's this service's origin. */
|
|
110
|
+
function serverBase(doc, version, publicUrl) {
|
|
111
|
+
const origin = new URL(publicUrl).origin;
|
|
112
|
+
try {
|
|
113
|
+
if (version === "2.0") {
|
|
114
|
+
const scheme = Array.isArray(doc.schemes) && doc.schemes[0] ? doc.schemes[0] : new URL(publicUrl).protocol.slice(0, -1);
|
|
115
|
+
const base = typeof doc.basePath === "string" ? doc.basePath : "";
|
|
116
|
+
const url = typeof doc.host === "string" ? new URL(`${scheme}://${doc.host}${base}`) : new URL(base || "/", publicUrl);
|
|
117
|
+
return { prefix: url.pathname.replace(/\/+$/, ""), sameOrigin: url.origin === origin };
|
|
118
|
+
}
|
|
119
|
+
const server = Array.isArray(doc.servers) ? doc.servers[0] : undefined;
|
|
120
|
+
if (!isObj(server) || typeof server.url !== "string")
|
|
121
|
+
return { prefix: "", sameOrigin: true };
|
|
122
|
+
const raw = server.url.replace(/\{([^}]+)\}/g, (_, name) => server.variables?.[name]?.default ?? "");
|
|
123
|
+
const url = new URL(raw, publicUrl);
|
|
124
|
+
return { prefix: url.pathname.replace(/\/+$/, ""), sameOrigin: url.origin === origin };
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return { prefix: "", sameOrigin: false };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function feedbackOperations(version, formUrl, rateUrl, existingIds) {
|
|
131
|
+
const id = (name) => (existingIds.has(name) ? {} : { operationId: name });
|
|
132
|
+
const idSchema = { type: "string", pattern: FEEDBACK_ID_PATTERN.source };
|
|
133
|
+
const outcome = { type: "string", enum: Object.keys(OUTCOMES) };
|
|
134
|
+
const issue = { type: "string", enum: Object.keys(ISSUES) };
|
|
135
|
+
const result = {
|
|
136
|
+
type: "object",
|
|
137
|
+
properties: { recorded: { type: "boolean" }, updated: { type: "boolean" }, error: { type: "string" } },
|
|
138
|
+
};
|
|
139
|
+
const submission = {
|
|
140
|
+
type: "object",
|
|
141
|
+
required: ["feedback_id", "outcome"],
|
|
142
|
+
additionalProperties: false,
|
|
143
|
+
properties: { feedback_id: idSchema, outcome, issue, note: { type: "string", maxLength: NOTE_MAX_LENGTH } },
|
|
144
|
+
};
|
|
145
|
+
const common = { security: [], "x-forge-feedback": true };
|
|
146
|
+
const descr = {
|
|
147
|
+
rate: `Records a free rating for a paid call. Enum values only. Same as: ${rateUrl}?feedback_id=FEEDBACK_ID&outcome=OUTCOME`,
|
|
148
|
+
summary: "Public summary of ratings that agents gave this service after paid calls.",
|
|
149
|
+
form: "Describes the rating fields and both submission methods. Submits nothing.",
|
|
150
|
+
submit: `Records a free rating with an optional note (max ${NOTE_MAX_LENGTH} characters, no user data).`,
|
|
151
|
+
};
|
|
152
|
+
if (version === "2.0") {
|
|
153
|
+
const ok = (description) => ({ description, schema: result });
|
|
154
|
+
const q = (name, required, s) => ({ name, in: "query", required, ...s });
|
|
155
|
+
return {
|
|
156
|
+
form: { ...common, ...id("forgeFeedbackForm"), summary: "Rating form", description: descr.form, produces: ["application/json"], responses: { "200": { description: "Rating fields" } } },
|
|
157
|
+
summary: { ...common, ...id("forgeFeedbackSummary"), summary: "Public rating summary", description: descr.summary, produces: ["application/json"], responses: { "200": { description: "Rating summary" } } },
|
|
158
|
+
submit: {
|
|
159
|
+
...common, ...id("forgeFeedbackSubmit"), summary: "Rate a paid call", description: descr.submit,
|
|
160
|
+
consumes: ["application/json"], produces: ["application/json"],
|
|
161
|
+
parameters: [{ name: "body", in: "body", required: true, schema: submission }],
|
|
162
|
+
responses: { "200": ok("Recorded"), default: ok("Not recorded") },
|
|
163
|
+
},
|
|
164
|
+
rate: {
|
|
165
|
+
...common, ...id("forgeFeedbackRate"), summary: "Rate a paid call (quick)", description: descr.rate, produces: ["application/json"],
|
|
166
|
+
parameters: [q("feedback_id", true, idSchema), q("outcome", true, outcome), q("issue", false, issue)],
|
|
167
|
+
responses: { "200": ok("Recorded"), default: ok("Not recorded") },
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const json = (schema) => ({ content: { "application/json": { schema } } });
|
|
172
|
+
const q = (name, required, schema) => ({ name, in: "query", required, schema });
|
|
173
|
+
return {
|
|
174
|
+
form: { ...common, ...id("forgeFeedbackForm"), summary: "Rating form", description: descr.form, responses: { "200": { description: "Rating fields", ...json({ type: "object" }) } } },
|
|
175
|
+
summary: { ...common, ...id("forgeFeedbackSummary"), summary: "Public rating summary", description: descr.summary, responses: { "200": { description: "Rating summary", ...json({ type: "object" }) } } },
|
|
176
|
+
submit: {
|
|
177
|
+
...common, ...id("forgeFeedbackSubmit"), summary: "Rate a paid call", description: descr.submit,
|
|
178
|
+
requestBody: { required: true, ...json(submission) },
|
|
179
|
+
responses: { "200": { description: "Recorded", ...json(result) }, default: { description: "Not recorded", ...json(result) } },
|
|
180
|
+
},
|
|
181
|
+
rate: {
|
|
182
|
+
...common, ...id("forgeFeedbackRate"), summary: "Rate a paid call (quick)", description: descr.rate,
|
|
183
|
+
parameters: [q("feedback_id", true, idSchema), q("outcome", true, outcome), q("issue", false, issue)],
|
|
184
|
+
responses: { "200": { description: "Recorded", ...json(result) }, default: { description: "Not recorded", ...json(result) } },
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
export function enrichOpenApi(input, options) {
|
|
189
|
+
const report = { version: null, enriched: false, serverPrefix: "", feedbackPaths: "skipped", operations: [], warnings: [] };
|
|
190
|
+
const version = detectVersion(input);
|
|
191
|
+
report.version = version;
|
|
192
|
+
if (!version) {
|
|
193
|
+
report.warnings.push("Not an OpenAPI 3.0-3.2 or Swagger 2.0 document; left unchanged.");
|
|
194
|
+
return { document: input, report };
|
|
195
|
+
}
|
|
196
|
+
const original = input;
|
|
197
|
+
if (isObj(original["x-forge-feedback"])) {
|
|
198
|
+
report.warnings.push("Document already carries x-forge-feedback; left unchanged.");
|
|
199
|
+
return { document: input, report };
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const doc = clone(original);
|
|
203
|
+
const origin = new URL(options.publicUrl).origin;
|
|
204
|
+
const base = options.basePath.replace(/\/+$/, "");
|
|
205
|
+
const rateUrl = `${origin}${base}/rate`;
|
|
206
|
+
const formUrl = `${origin}${base}`;
|
|
207
|
+
const props = feedbackProperties(rateUrl, options.hintField);
|
|
208
|
+
const isPaid = options.isPaidOperation ??
|
|
209
|
+
((_m, _p, op) => (isObj(op.responses) && "402" in op.responses) || isObj(op["x-payment-info"]));
|
|
210
|
+
const methods = version === "3.2" ? [...METHODS, "query"] : METHODS;
|
|
211
|
+
const { prefix, sameOrigin } = serverBase(doc, version, options.publicUrl);
|
|
212
|
+
report.serverPrefix = prefix;
|
|
213
|
+
if (!isObj(doc.paths)) {
|
|
214
|
+
if (version === "3.1" || version === "3.2")
|
|
215
|
+
doc.paths = {};
|
|
216
|
+
else
|
|
217
|
+
throw new Error("paths is missing");
|
|
218
|
+
}
|
|
219
|
+
const operationIds = new Set();
|
|
220
|
+
for (const [path, item] of Object.entries(doc.paths)) {
|
|
221
|
+
if (!isObj(item))
|
|
222
|
+
continue;
|
|
223
|
+
for (const method of methods) {
|
|
224
|
+
const op = item[method];
|
|
225
|
+
if (!isObj(op))
|
|
226
|
+
continue;
|
|
227
|
+
if (typeof op.operationId === "string")
|
|
228
|
+
operationIds.add(op.operationId);
|
|
229
|
+
if (typeof item.$ref === "string") {
|
|
230
|
+
report.warnings.push(`${method.toUpperCase()} ${path}: path item uses $ref; not modified.`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (!isPaid(method, path, op))
|
|
234
|
+
continue;
|
|
235
|
+
const opReport = { method: method.toUpperCase(), path, responses: {}, reasons: [] };
|
|
236
|
+
report.operations.push(opReport);
|
|
237
|
+
if (options.describeOperations ?? true)
|
|
238
|
+
op.description = appendSentence(op.description, options.sentence, options.marker);
|
|
239
|
+
const produces = (op.produces ?? doc.produces);
|
|
240
|
+
for (const [code, rawResponse] of Object.entries(isObj(op.responses) ? op.responses : {})) {
|
|
241
|
+
if (!is2xx(code))
|
|
242
|
+
continue;
|
|
243
|
+
let response = rawResponse;
|
|
244
|
+
if (isObj(response) && typeof response.$ref === "string") {
|
|
245
|
+
const resolved = resolveRef(doc, response.$ref);
|
|
246
|
+
if (!resolved) {
|
|
247
|
+
opReport.responses[code] = "incompatible";
|
|
248
|
+
opReport.reasons.push(`${code}: unresolvable_ref`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
response = clone(resolved);
|
|
252
|
+
}
|
|
253
|
+
if (!isObj(response))
|
|
254
|
+
continue;
|
|
255
|
+
const schemas = [];
|
|
256
|
+
if (version === "2.0") {
|
|
257
|
+
const jsonish = !Array.isArray(produces) || produces.some((t) => typeof t === "string" && isJsonMedia(t));
|
|
258
|
+
if (jsonish && "schema" in response)
|
|
259
|
+
schemas.push({ get: () => response.schema, set: (s) => (response.schema = s) });
|
|
260
|
+
}
|
|
261
|
+
else if (isObj(response.content)) {
|
|
262
|
+
for (const [media, entry] of Object.entries(response.content)) {
|
|
263
|
+
if (isJsonMedia(media) && isObj(entry) && "schema" in entry) {
|
|
264
|
+
schemas.push({ get: () => entry.schema, set: (s) => (entry.schema = s) });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (!schemas.length) {
|
|
269
|
+
opReport.responses[code] = "undocumented";
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const results = schemas.map((s) => extendSchema(doc, s.get(), props));
|
|
273
|
+
const failure = results.find((r) => "reason" in r);
|
|
274
|
+
if (failure) {
|
|
275
|
+
opReport.responses[code] = "incompatible";
|
|
276
|
+
opReport.reasons.push(`${code}: ${failure.reason}`);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
results.forEach((r, i) => schemas[i].set(r.schema));
|
|
280
|
+
op.responses[code] = response; // inline copy if it was a shared $ref
|
|
281
|
+
opReport.responses[code] = "extended";
|
|
282
|
+
}
|
|
283
|
+
// agentcash-style payment metadata carries its own output schema.
|
|
284
|
+
const info = op["x-payment-info"];
|
|
285
|
+
if (isObj(info) && isObj(info.outputSchema)) {
|
|
286
|
+
const r = extendSchema(doc, info.outputSchema, props);
|
|
287
|
+
if ("schema" in r)
|
|
288
|
+
info.outputSchema = r.schema;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// Feedback routes. Paths are relative to the server prefix when it's this origin;
|
|
293
|
+
// otherwise (3.x) the path items carry their own servers entry.
|
|
294
|
+
const inPrefix = sameOrigin && (base === prefix || base.startsWith(`${prefix}/`));
|
|
295
|
+
const docBase = inPrefix ? base.slice(prefix.length) || "/" : base;
|
|
296
|
+
const paths = doc.paths;
|
|
297
|
+
const conflicts = [docBase, `${docBase}/rate`, `${docBase}/summary`].filter((p) => isObj(paths[p]));
|
|
298
|
+
if (conflicts.length) {
|
|
299
|
+
report.feedbackPaths = "conflict";
|
|
300
|
+
report.warnings.push(`Document already defines ${conflicts.join(" and ")}. The SDK's routes answer these paths at runtime; set basePath to avoid shadowing them.`);
|
|
301
|
+
}
|
|
302
|
+
else if (!inPrefix && version === "2.0") {
|
|
303
|
+
report.warnings.push("Swagger 2.0 host/basePath doesn't cover the feedback path; feedback routes not documented.");
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
const ops = feedbackOperations(version, formUrl, rateUrl, operationIds);
|
|
307
|
+
const servers = inPrefix ? {} : { servers: [{ url: origin }] };
|
|
308
|
+
paths[docBase] = { ...servers, get: ops.form, post: ops.submit };
|
|
309
|
+
paths[`${docBase}/rate`] = { ...servers, get: ops.rate };
|
|
310
|
+
paths[`${docBase}/summary`] = { ...servers, get: ops.summary };
|
|
311
|
+
report.feedbackPaths = "added";
|
|
312
|
+
}
|
|
313
|
+
// Guidance: append to whichever x-guidance exists, preferring info-level.
|
|
314
|
+
if (isObj(doc.info)) {
|
|
315
|
+
if (typeof doc.info["x-guidance"] === "string" || typeof doc["x-guidance"] !== "string") {
|
|
316
|
+
doc.info["x-guidance"] = appendSentence(doc.info["x-guidance"], options.sentence, options.marker);
|
|
317
|
+
}
|
|
318
|
+
if (typeof doc["x-guidance"] === "string")
|
|
319
|
+
doc["x-guidance"] = appendSentence(doc["x-guidance"], options.sentence, options.marker);
|
|
320
|
+
}
|
|
321
|
+
doc["x-forge-feedback"] = {
|
|
322
|
+
protocol: PROTOCOL,
|
|
323
|
+
rate: `${rateUrl}?feedback_id={feedback_id}&outcome={outcome}`,
|
|
324
|
+
submit: formUrl,
|
|
325
|
+
id_header: "Forge-Feedback-Id",
|
|
326
|
+
outcomes: Object.keys(OUTCOMES),
|
|
327
|
+
issues: Object.keys(ISSUES),
|
|
328
|
+
};
|
|
329
|
+
JSON.stringify(doc); // must stay serializable
|
|
330
|
+
report.enriched = true;
|
|
331
|
+
return { document: doc, report };
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
report.warnings.push(`Enrichment failed (${error instanceof Error ? error.message : String(error)}); served unchanged.`);
|
|
335
|
+
report.enriched = false;
|
|
336
|
+
report.operations = [];
|
|
337
|
+
return { document: input, report };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/** Runtime lookup: may feedback fields be added to this response body without contradicting the document? */
|
|
341
|
+
export function createOperationIndex(report) {
|
|
342
|
+
const entries = report.operations.map((op) => {
|
|
343
|
+
const full = `${report.serverPrefix}${op.path}`;
|
|
344
|
+
const pattern = full
|
|
345
|
+
.split(/(\{[^}]+\})/)
|
|
346
|
+
.map((part) => (/^\{[^}]+\}$/.test(part) ? "[^/]+" : part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
|
|
347
|
+
.join("");
|
|
348
|
+
return { method: op.method, regex: new RegExp(`^${pattern}/?$`), responses: op.responses };
|
|
349
|
+
});
|
|
350
|
+
return (method, path, status) => {
|
|
351
|
+
const op = entries.find((e) => e.method === method && e.regex.test(path));
|
|
352
|
+
if (!op)
|
|
353
|
+
return true; // undocumented operation: nothing to contradict
|
|
354
|
+
const support = op.responses[String(status)] ?? op.responses[`${String(status)[0]}XX`] ?? op.responses[`${String(status)[0]}xx`];
|
|
355
|
+
return support !== "incompatible";
|
|
356
|
+
};
|
|
357
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type ForgeEvent = {
|
|
2
|
+
type: "challenge";
|
|
3
|
+
route: string;
|
|
4
|
+
ts: number;
|
|
5
|
+
user_agent?: string;
|
|
6
|
+
} | {
|
|
7
|
+
type: "interaction";
|
|
8
|
+
feedback_id: string;
|
|
9
|
+
route: string;
|
|
10
|
+
status: number;
|
|
11
|
+
latency_ms: number;
|
|
12
|
+
payer?: string;
|
|
13
|
+
network?: string;
|
|
14
|
+
amount?: string;
|
|
15
|
+
user_agent?: string;
|
|
16
|
+
ts: number;
|
|
17
|
+
};
|
|
18
|
+
/** Batches events to the backend in the background. Drops the oldest events if the backend stays down. */
|
|
19
|
+
export declare class EventReporter {
|
|
20
|
+
private readonly url;
|
|
21
|
+
private readonly apiKey;
|
|
22
|
+
private readonly fetchImpl;
|
|
23
|
+
private readonly onError;
|
|
24
|
+
private queue;
|
|
25
|
+
private timer;
|
|
26
|
+
private flushing;
|
|
27
|
+
sent: number;
|
|
28
|
+
dropped: number;
|
|
29
|
+
constructor(url: string, apiKey: string, fetchImpl: typeof fetch, onError: (error: unknown) => void, intervalMs: number);
|
|
30
|
+
push(event: ForgeEvent): void;
|
|
31
|
+
flush(): Promise<void>;
|
|
32
|
+
private drain;
|
|
33
|
+
close(): Promise<void>;
|
|
34
|
+
}
|
package/dist/reporter.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const MAX_QUEUE = 1000;
|
|
2
|
+
const BATCH_SIZE = 100;
|
|
3
|
+
/** Batches events to the backend in the background. Drops the oldest events if the backend stays down. */
|
|
4
|
+
export class EventReporter {
|
|
5
|
+
url;
|
|
6
|
+
apiKey;
|
|
7
|
+
fetchImpl;
|
|
8
|
+
onError;
|
|
9
|
+
queue = [];
|
|
10
|
+
timer;
|
|
11
|
+
flushing = null;
|
|
12
|
+
sent = 0;
|
|
13
|
+
dropped = 0;
|
|
14
|
+
constructor(url, apiKey, fetchImpl, onError, intervalMs) {
|
|
15
|
+
this.url = url;
|
|
16
|
+
this.apiKey = apiKey;
|
|
17
|
+
this.fetchImpl = fetchImpl;
|
|
18
|
+
this.onError = onError;
|
|
19
|
+
this.timer = setInterval(() => void this.flush(), intervalMs);
|
|
20
|
+
this.timer.unref();
|
|
21
|
+
}
|
|
22
|
+
push(event) {
|
|
23
|
+
this.queue.push(event);
|
|
24
|
+
if (this.queue.length > MAX_QUEUE)
|
|
25
|
+
this.dropped += this.queue.splice(0, this.queue.length - MAX_QUEUE).length;
|
|
26
|
+
if (this.queue.length >= BATCH_SIZE)
|
|
27
|
+
void this.flush();
|
|
28
|
+
}
|
|
29
|
+
flush() {
|
|
30
|
+
if (!this.flushing)
|
|
31
|
+
this.flushing = this.drain().finally(() => (this.flushing = null));
|
|
32
|
+
return this.flushing;
|
|
33
|
+
}
|
|
34
|
+
async drain() {
|
|
35
|
+
while (this.queue.length) {
|
|
36
|
+
const batch = this.queue.splice(0, BATCH_SIZE);
|
|
37
|
+
try {
|
|
38
|
+
const response = await this.fetchImpl(this.url, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${this.apiKey}` },
|
|
41
|
+
body: JSON.stringify({ events: batch }),
|
|
42
|
+
signal: AbortSignal.timeout(5000),
|
|
43
|
+
});
|
|
44
|
+
if (!response.ok)
|
|
45
|
+
throw new Error(`event ingest returned ${response.status}`);
|
|
46
|
+
this.sent += batch.length;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
this.queue.unshift(...batch);
|
|
50
|
+
this.onError(error);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async close() {
|
|
56
|
+
clearInterval(this.timer);
|
|
57
|
+
await this.flush();
|
|
58
|
+
}
|
|
59
|
+
}
|
package/dist/values.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export declare const PROTOCOL = "forge-feedback/0";
|
|
2
|
+
export declare const OUTCOMES: {
|
|
3
|
+
readonly fully: "The call accomplished what you called it for.";
|
|
4
|
+
readonly partially: "It helped, but only partly.";
|
|
5
|
+
readonly no: "It did not accomplish what you called it for.";
|
|
6
|
+
readonly not_evaluated: "You did not check the result.";
|
|
7
|
+
};
|
|
8
|
+
export declare const ISSUES: {
|
|
9
|
+
readonly wrong_output: "The result is incorrect or poor quality.";
|
|
10
|
+
readonly unmet_expectation: "It worked as built, but not what the description or schema led you to expect.";
|
|
11
|
+
readonly schema_mismatch: "The response shape did not match the declared schema.";
|
|
12
|
+
readonly slow: "Too slow for the task.";
|
|
13
|
+
readonly unclear_docs: "Hard to work out how to call it.";
|
|
14
|
+
readonly too_expensive: "The price was not worth the result.";
|
|
15
|
+
};
|
|
16
|
+
export type Outcome = keyof typeof OUTCOMES;
|
|
17
|
+
export type Issue = keyof typeof ISSUES;
|
|
18
|
+
export declare const NOTE_MAX_LENGTH = 280;
|
|
19
|
+
export interface Submission {
|
|
20
|
+
feedback_id: string;
|
|
21
|
+
outcome: Outcome;
|
|
22
|
+
issue: Issue | null;
|
|
23
|
+
note: string | null;
|
|
24
|
+
}
|
|
25
|
+
export type ParseResult = {
|
|
26
|
+
ok: true;
|
|
27
|
+
value: Submission;
|
|
28
|
+
} | {
|
|
29
|
+
ok: false;
|
|
30
|
+
error: string;
|
|
31
|
+
};
|
|
32
|
+
/** Validate a submission from either the GET query or the POST body. Empty strings count as absent. */
|
|
33
|
+
export declare function parseSubmission(input: Record<string, unknown>, { allowNote }: {
|
|
34
|
+
allowNote: boolean;
|
|
35
|
+
}): ParseResult;
|
package/dist/values.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export const PROTOCOL = "forge-feedback/0";
|
|
2
|
+
export const OUTCOMES = {
|
|
3
|
+
fully: "The call accomplished what you called it for.",
|
|
4
|
+
partially: "It helped, but only partly.",
|
|
5
|
+
no: "It did not accomplish what you called it for.",
|
|
6
|
+
not_evaluated: "You did not check the result.",
|
|
7
|
+
};
|
|
8
|
+
export const ISSUES = {
|
|
9
|
+
wrong_output: "The result is incorrect or poor quality.",
|
|
10
|
+
unmet_expectation: "It worked as built, but not what the description or schema led you to expect.",
|
|
11
|
+
schema_mismatch: "The response shape did not match the declared schema.",
|
|
12
|
+
slow: "Too slow for the task.",
|
|
13
|
+
unclear_docs: "Hard to work out how to call it.",
|
|
14
|
+
too_expensive: "The price was not worth the result.",
|
|
15
|
+
};
|
|
16
|
+
export const NOTE_MAX_LENGTH = 280;
|
|
17
|
+
const isOutcome = (v) => typeof v === "string" && Object.hasOwn(OUTCOMES, v);
|
|
18
|
+
const isIssue = (v) => typeof v === "string" && Object.hasOwn(ISSUES, v);
|
|
19
|
+
/** Validate a submission from either the GET query or the POST body. Empty strings count as absent. */
|
|
20
|
+
export function parseSubmission(input, { allowNote }) {
|
|
21
|
+
const present = (v) => v !== undefined && v !== null && v !== "";
|
|
22
|
+
const { feedback_id, outcome, issue, note } = input;
|
|
23
|
+
if (typeof feedback_id !== "string" || !feedback_id)
|
|
24
|
+
return { ok: false, error: "missing_feedback_id" };
|
|
25
|
+
if (!present(outcome))
|
|
26
|
+
return { ok: false, error: "missing_outcome" };
|
|
27
|
+
if (!isOutcome(outcome))
|
|
28
|
+
return { ok: false, error: "invalid_outcome" };
|
|
29
|
+
if (present(issue) && !isIssue(issue))
|
|
30
|
+
return { ok: false, error: "invalid_issue" };
|
|
31
|
+
if (present(note)) {
|
|
32
|
+
if (!allowNote)
|
|
33
|
+
return { ok: false, error: "note_requires_post" };
|
|
34
|
+
if (typeof note !== "string" || note.length > NOTE_MAX_LENGTH)
|
|
35
|
+
return { ok: false, error: "invalid_note" };
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
ok: true,
|
|
39
|
+
value: {
|
|
40
|
+
feedback_id,
|
|
41
|
+
outcome,
|
|
42
|
+
issue: present(issue) ? issue : null,
|
|
43
|
+
note: present(note) ? note.trim() || null : null,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|