@nkmc/gateway 0.1.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/dist/chunk-56RA53VS.js +37 -0
- package/dist/chunk-CZJ75YTV.js +969 -0
- package/dist/chunk-QGM4M3NI.js +37 -0
- package/dist/http.cjs +1772 -0
- package/dist/http.d.cts +49 -0
- package/dist/http.d.ts +49 -0
- package/dist/http.js +748 -0
- package/dist/index.cjs +2436 -0
- package/dist/index.d.cts +436 -0
- package/dist/index.d.ts +436 -0
- package/dist/index.js +1434 -0
- package/dist/proxy-ClPcDgsO.d.cts +283 -0
- package/dist/proxy-qpda1ANS.d.ts +283 -0
- package/dist/proxy.cjs +148 -0
- package/dist/proxy.d.cts +6 -0
- package/dist/proxy.d.ts +6 -0
- package/dist/proxy.js +90 -0
- package/dist/testing.cjs +865 -0
- package/dist/testing.d.cts +12 -0
- package/dist/testing.d.ts +12 -0
- package/dist/testing.js +831 -0
- package/dist/tunnels-BviBEaih.d.cts +12 -0
- package/dist/tunnels-DFHNgmN7.d.ts +12 -0
- package/dist/types-C6JC9oTm.d.cts +21 -0
- package/dist/types-C6JC9oTm.d.ts +21 -0
- package/package.json +47 -0
- package/src/__tests__/sqlite-integration.test.ts +384 -0
- package/src/credential/d1-vault.ts +134 -0
- package/src/credential/memory-vault.ts +50 -0
- package/src/credential/types.ts +16 -0
- package/src/d1/__tests__/sqlite-adapter.test.ts +75 -0
- package/src/d1/sqlite-adapter.ts +59 -0
- package/src/d1/types.ts +22 -0
- package/src/federation/__tests__/d1-peer-store.test.ts +218 -0
- package/src/federation/__tests__/peer-client.test.ts +205 -0
- package/src/federation/__tests__/peer-store.test.ts +114 -0
- package/src/federation/d1-peer-store.ts +164 -0
- package/src/federation/peer-backend.ts +60 -0
- package/src/federation/peer-client.ts +122 -0
- package/src/federation/peer-store.ts +45 -0
- package/src/federation/types.ts +39 -0
- package/src/http/app.ts +152 -0
- package/src/http/lib/dns.ts +30 -0
- package/src/http/middleware/admin-auth.ts +18 -0
- package/src/http/middleware/agent-auth.ts +27 -0
- package/src/http/middleware/publish-auth.ts +39 -0
- package/src/http/routes/__tests__/federation.test.ts +364 -0
- package/src/http/routes/__tests__/peers.test.ts +290 -0
- package/src/http/routes/__tests__/proxy.test.ts +159 -0
- package/src/http/routes/auth.ts +39 -0
- package/src/http/routes/byok.ts +62 -0
- package/src/http/routes/credentials.ts +40 -0
- package/src/http/routes/domains.ts +174 -0
- package/src/http/routes/federation.ts +170 -0
- package/src/http/routes/fs.ts +89 -0
- package/src/http/routes/peers.ts +103 -0
- package/src/http/routes/proxy.ts +57 -0
- package/src/http/routes/registry.ts +222 -0
- package/src/http/routes/tunnels.ts +124 -0
- package/src/http.ts +9 -0
- package/src/index.ts +63 -0
- package/src/metering/d1-store.ts +123 -0
- package/src/metering/memory-store.ts +29 -0
- package/src/metering/pricing-guard.ts +68 -0
- package/src/metering/types.ts +25 -0
- package/src/onboard/apis-guru.ts +64 -0
- package/src/onboard/index.ts +4 -0
- package/src/onboard/manifest.ts +362 -0
- package/src/onboard/pipeline.ts +214 -0
- package/src/onboard/types.ts +72 -0
- package/src/proxy/__tests__/tool-registry.test.ts +93 -0
- package/src/proxy/tool-registry.ts +122 -0
- package/src/proxy.ts +12 -0
- package/src/registry/context7-backend.ts +93 -0
- package/src/registry/context7.ts +54 -0
- package/src/registry/d1-store.ts +242 -0
- package/src/registry/memory-store.ts +101 -0
- package/src/registry/openapi-compiler.ts +284 -0
- package/src/registry/resolver.ts +196 -0
- package/src/registry/rpc-compiler.ts +142 -0
- package/src/registry/skill-parser.ts +119 -0
- package/src/registry/skill-to-config.ts +239 -0
- package/src/registry/source-refresher.ts +83 -0
- package/src/registry/types.ts +129 -0
- package/src/registry/virtual-files.ts +76 -0
- package/src/testing/sqlite-d1.ts +64 -0
- package/src/testing.ts +2 -0
- package/src/tunnel/__tests__/cloudflare-provider.test.ts +255 -0
- package/src/tunnel/__tests__/tunnel.test.ts +542 -0
- package/src/tunnel/cloudflare-provider.ts +121 -0
- package/src/tunnel/memory-store.ts +30 -0
- package/src/tunnel/types.ts +28 -0
- package/test/credential/d1-vault.test.ts +127 -0
- package/test/credential/injection.test.ts +67 -0
- package/test/credential/memory-vault.test.ts +63 -0
- package/test/http/app.test.ts +300 -0
- package/test/http/byok-e2e.test.ts +240 -0
- package/test/http/byok.test.ts +115 -0
- package/test/http/credentials.test.ts +57 -0
- package/test/http/e2e.test.ts +260 -0
- package/test/integration/authenticated-apis.test.ts +185 -0
- package/test/integration/free-apis-e2e.test.ts +222 -0
- package/test/metering/d1-store.test.ts +82 -0
- package/test/metering/memory-store.test.ts +76 -0
- package/test/metering/pricing-guard.test.ts +108 -0
- package/test/onboard/apis-guru.test.ts +57 -0
- package/test/onboard/e2e.test.ts +70 -0
- package/test/onboard/pipeline.test.ts +318 -0
- package/test/onboard/real-apis.test.ts +483 -0
- package/test/registry/compilation-correctness.test.ts +132 -0
- package/test/registry/context7-backend.test.ts +88 -0
- package/test/registry/context7-e2e.test.ts +92 -0
- package/test/registry/context7.test.ts +73 -0
- package/test/registry/d1-store.test.ts +184 -0
- package/test/registry/integration.test.ts +129 -0
- package/test/registry/lazy-mount.test.ts +138 -0
- package/test/registry/memory-store.test.ts +171 -0
- package/test/registry/openapi-compiler.test.ts +267 -0
- package/test/registry/openapi-e2e.test.ts +154 -0
- package/test/registry/passthrough-e2e.test.ts +109 -0
- package/test/registry/resolver-peer.test.ts +299 -0
- package/test/registry/resolver.test.ts +228 -0
- package/test/registry/rpc-compiler.test.ts +112 -0
- package/test/registry/skill-parser.test.ts +151 -0
- package/test/registry/skill-to-config.test.ts +151 -0
- package/test/registry/skill-to-rpc-config.test.ts +142 -0
- package/test/registry/source-refresher.test.ts +90 -0
- package/test/registry/virtual-files.test.ts +96 -0
- package/tsconfig.json +4 -0
- package/tsup.config.ts +8 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// packages/gateway/src/registry/rpc-compiler.ts
|
|
2
|
+
|
|
3
|
+
import type { RpcManifestDef } from "../onboard/types.js";
|
|
4
|
+
import type { ServiceRecord, EndpointRecord, RpcSourceMeta } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export interface RpcCompileResult {
|
|
7
|
+
record: ServiceRecord;
|
|
8
|
+
skillMd: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Compile an RpcManifestDef into a ServiceRecord suitable for the registry.
|
|
13
|
+
*
|
|
14
|
+
* Each RPC method becomes an EndpointRecord with method="RPC" and path=rpcMethod.
|
|
15
|
+
* Resources are inferred from the method prefix (e.g. "eth_getBalance" → "balances")
|
|
16
|
+
* or from explicit `resource` annotations on each method.
|
|
17
|
+
*/
|
|
18
|
+
export function compileRpcDef(
|
|
19
|
+
domain: string,
|
|
20
|
+
rpcDef: RpcManifestDef,
|
|
21
|
+
options?: { version?: string; isFirstParty?: boolean },
|
|
22
|
+
): RpcCompileResult {
|
|
23
|
+
const convention = rpcDef.convention ?? "raw";
|
|
24
|
+
|
|
25
|
+
// Build endpoints
|
|
26
|
+
const endpoints: EndpointRecord[] = rpcDef.methods.map((m) => ({
|
|
27
|
+
method: "RPC",
|
|
28
|
+
path: m.rpcMethod,
|
|
29
|
+
description: m.description,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// Group methods by resource for SourceConfig
|
|
33
|
+
const resourceMap = new Map<string, { name: string; idField?: string; methods: Record<string, string> }>();
|
|
34
|
+
|
|
35
|
+
for (const m of rpcDef.methods) {
|
|
36
|
+
const resName = m.resource ?? inferResource(m.rpcMethod);
|
|
37
|
+
if (!resourceMap.has(resName)) {
|
|
38
|
+
resourceMap.set(resName, { name: resName, methods: {} });
|
|
39
|
+
}
|
|
40
|
+
const entry = resourceMap.get(resName)!;
|
|
41
|
+
if (m.fsOp) {
|
|
42
|
+
entry.methods[m.fsOp] = m.rpcMethod;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const rpcMeta: RpcSourceMeta = {
|
|
47
|
+
rpcUrl: rpcDef.url,
|
|
48
|
+
convention,
|
|
49
|
+
resources: Array.from(resourceMap.values()),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Generate skill.md
|
|
53
|
+
const skillMd = generateSkillMd(domain, rpcDef, endpoints);
|
|
54
|
+
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
const record: ServiceRecord = {
|
|
57
|
+
domain,
|
|
58
|
+
name: domain,
|
|
59
|
+
description: `JSON-RPC service at ${domain}`,
|
|
60
|
+
version: options?.version ?? "1.0",
|
|
61
|
+
roles: ["agent"],
|
|
62
|
+
skillMd,
|
|
63
|
+
endpoints,
|
|
64
|
+
isFirstParty: options?.isFirstParty ?? false,
|
|
65
|
+
createdAt: now,
|
|
66
|
+
updatedAt: now,
|
|
67
|
+
status: "active",
|
|
68
|
+
isDefault: true,
|
|
69
|
+
source: {
|
|
70
|
+
type: "jsonrpc",
|
|
71
|
+
url: rpcDef.url,
|
|
72
|
+
rpc: rpcMeta,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return { record, skillMd };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Infer a resource name from an RPC method name.
|
|
81
|
+
* e.g. "eth_getBlockByNumber" → "blocks"
|
|
82
|
+
* "eth_getBalance" → "balances"
|
|
83
|
+
* "net_version" → "net"
|
|
84
|
+
*/
|
|
85
|
+
function inferResource(rpcMethod: string): string {
|
|
86
|
+
// Split by underscore: "eth_getBlockByNumber" → ["eth", "getBlockByNumber"]
|
|
87
|
+
const underscoreIdx = rpcMethod.indexOf("_");
|
|
88
|
+
if (underscoreIdx < 0) return rpcMethod;
|
|
89
|
+
|
|
90
|
+
const action = rpcMethod.slice(underscoreIdx + 1);
|
|
91
|
+
|
|
92
|
+
// Strip common verb prefixes
|
|
93
|
+
const verbPrefixes = ["get", "send", "subscribe", "unsubscribe", "new", "call"];
|
|
94
|
+
let noun = action;
|
|
95
|
+
for (const prefix of verbPrefixes) {
|
|
96
|
+
if (action.startsWith(prefix) && action.length > prefix.length) {
|
|
97
|
+
noun = action.slice(prefix.length);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Split camelCase on first capital to get the noun: "BlockByNumber" → "Block"
|
|
103
|
+
const camelMatch = noun.match(/^([A-Z][a-z]+)/);
|
|
104
|
+
if (camelMatch) {
|
|
105
|
+
noun = camelMatch[1];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Pluralize naive: add 's' if not already ending in 's'
|
|
109
|
+
const lower = noun.toLowerCase();
|
|
110
|
+
return lower.endsWith("s") ? lower : lower + "s";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function generateSkillMd(
|
|
114
|
+
domain: string,
|
|
115
|
+
rpcDef: RpcManifestDef,
|
|
116
|
+
endpoints: EndpointRecord[],
|
|
117
|
+
): string {
|
|
118
|
+
const lines: string[] = [
|
|
119
|
+
"---",
|
|
120
|
+
`name: "${domain}"`,
|
|
121
|
+
`version: "1.0"`,
|
|
122
|
+
`roles: [agent]`,
|
|
123
|
+
"---",
|
|
124
|
+
"",
|
|
125
|
+
`# ${domain}`,
|
|
126
|
+
"",
|
|
127
|
+
`JSON-RPC service at ${rpcDef.url}`,
|
|
128
|
+
"",
|
|
129
|
+
`Convention: ${rpcDef.convention ?? "raw"}`,
|
|
130
|
+
"",
|
|
131
|
+
"## RPC Methods",
|
|
132
|
+
"",
|
|
133
|
+
"| method | description |",
|
|
134
|
+
"|--------|-------------|",
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
for (const ep of endpoints) {
|
|
138
|
+
lines.push(`| ${ep.path} | ${ep.description} |`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return lines.join("\n");
|
|
142
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// packages/gateway/src/registry/skill-parser.ts
|
|
2
|
+
import { parse as parseYaml } from "yaml";
|
|
3
|
+
import type { ServiceRecord, EndpointRecord, EndpointPricing } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export interface ParseOptions {
|
|
6
|
+
isFirstParty?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function parseSkillMd(
|
|
10
|
+
domain: string,
|
|
11
|
+
raw: string,
|
|
12
|
+
options?: ParseOptions,
|
|
13
|
+
): ServiceRecord {
|
|
14
|
+
const { frontmatter, body } = extractFrontmatter(raw);
|
|
15
|
+
const parsed = (parseYaml(frontmatter) as {
|
|
16
|
+
name?: string;
|
|
17
|
+
version?: string;
|
|
18
|
+
roles?: string[];
|
|
19
|
+
}) ?? {};
|
|
20
|
+
|
|
21
|
+
const description = extractDescription(body);
|
|
22
|
+
const endpoints = extractEndpoints(body);
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
domain,
|
|
27
|
+
name: parsed.name ?? domain,
|
|
28
|
+
description,
|
|
29
|
+
version: parsed.version ?? "0.0",
|
|
30
|
+
roles: parsed.roles ?? ["agent"],
|
|
31
|
+
skillMd: raw,
|
|
32
|
+
endpoints,
|
|
33
|
+
isFirstParty: options?.isFirstParty ?? false,
|
|
34
|
+
createdAt: now,
|
|
35
|
+
updatedAt: now,
|
|
36
|
+
status: "active",
|
|
37
|
+
isDefault: true,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function parsePricingAnnotation(text: string): EndpointPricing | undefined {
|
|
42
|
+
// Matches patterns like "0.05 USDC / call", "0.1 USDC / byte"
|
|
43
|
+
const match = text.match(
|
|
44
|
+
/(\d+(?:\.\d+)?)\s+(\w+)\s*\/\s*(call|byte|minute|次)/i,
|
|
45
|
+
);
|
|
46
|
+
if (!match) return undefined;
|
|
47
|
+
return {
|
|
48
|
+
cost: parseFloat(match[1]),
|
|
49
|
+
currency: match[2].toUpperCase(),
|
|
50
|
+
per: match[3] === "次" ? "call" : (match[3].toLowerCase() as "call" | "byte" | "minute"),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function extractFrontmatter(raw: string): { frontmatter: string; body: string } {
|
|
55
|
+
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
56
|
+
if (!match) return { frontmatter: "", body: raw };
|
|
57
|
+
return { frontmatter: match[1], body: match[2] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function extractDescription(body: string): string {
|
|
61
|
+
const lines = body.split("\n");
|
|
62
|
+
let foundTitle = false;
|
|
63
|
+
const descLines: string[] = [];
|
|
64
|
+
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
if (!foundTitle) {
|
|
67
|
+
if (line.startsWith("# ")) foundTitle = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (line.startsWith("## ")) break;
|
|
71
|
+
const trimmed = line.trim();
|
|
72
|
+
if (trimmed === "" && descLines.length > 0) break;
|
|
73
|
+
if (trimmed !== "") descLines.push(trimmed);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return descLines.join(" ");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function extractEndpoints(body: string): EndpointRecord[] {
|
|
80
|
+
const endpoints: EndpointRecord[] = [];
|
|
81
|
+
const lines = body.split("\n");
|
|
82
|
+
|
|
83
|
+
let inApiSection = false;
|
|
84
|
+
let currentHeading: string | null = null;
|
|
85
|
+
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
if (line.startsWith("## API")) {
|
|
88
|
+
inApiSection = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (inApiSection && line.startsWith("## ") && !line.startsWith("## API")) {
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
if (!inApiSection) continue;
|
|
95
|
+
|
|
96
|
+
if (line.startsWith("### ")) {
|
|
97
|
+
currentHeading = line.slice(4).trim();
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const endpointMatch = line.match(/^`(GET|POST|PUT|PATCH|DELETE)\s+(\S+)`/);
|
|
102
|
+
if (endpointMatch && currentHeading) {
|
|
103
|
+
const afterBacktick = line.slice(line.indexOf("`", 1) + 1).trim();
|
|
104
|
+
const pricing = afterBacktick.startsWith("—")
|
|
105
|
+
? parsePricingAnnotation(afterBacktick.slice(1).trim())
|
|
106
|
+
: undefined;
|
|
107
|
+
|
|
108
|
+
endpoints.push({
|
|
109
|
+
method: endpointMatch[1],
|
|
110
|
+
path: endpointMatch[2],
|
|
111
|
+
description: currentHeading,
|
|
112
|
+
...(pricing ? { pricing } : {}),
|
|
113
|
+
});
|
|
114
|
+
currentHeading = null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return endpoints;
|
|
119
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// packages/gateway/src/registry/skill-to-config.ts
|
|
2
|
+
import type { HttpBackendConfig, HttpResource, HttpEndpoint, RpcResource, RpcMethod, RpcCallContext } from "@nkmc/agent-fs";
|
|
3
|
+
import type { ServiceRecord, RpcSourceMeta } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export function skillToHttpConfig(record: ServiceRecord): HttpBackendConfig {
|
|
6
|
+
let baseUrl = `https://${record.domain}`;
|
|
7
|
+
if (record.source?.basePath) {
|
|
8
|
+
baseUrl += record.source.basePath;
|
|
9
|
+
}
|
|
10
|
+
const resources = extractResources(record.skillMd);
|
|
11
|
+
const endpoints = extractHttpEndpoints(record.skillMd);
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
baseUrl,
|
|
15
|
+
resources,
|
|
16
|
+
endpoints,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function extractResources(skillMd: string): HttpResource[] {
|
|
21
|
+
const resources: HttpResource[] = [];
|
|
22
|
+
const lines = skillMd.split("\n");
|
|
23
|
+
|
|
24
|
+
let inSchema = false;
|
|
25
|
+
let current: {
|
|
26
|
+
name: string;
|
|
27
|
+
fields: { name: string; type: string; description: string }[];
|
|
28
|
+
} | null = null;
|
|
29
|
+
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
if (line.startsWith("## Schema")) {
|
|
32
|
+
inSchema = true;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (inSchema && line.startsWith("## ") && !line.startsWith("## Schema")) {
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
if (!inSchema) continue;
|
|
39
|
+
|
|
40
|
+
const tableMatch = line.match(/^### (\w+)\s/);
|
|
41
|
+
if (tableMatch) {
|
|
42
|
+
if (current) resources.push(toHttpResource(current));
|
|
43
|
+
current = { name: tableMatch[1], fields: [] };
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (current && line.startsWith("|") && !line.startsWith("|--") && !line.startsWith("| field")) {
|
|
48
|
+
const cells = line.split("|").map((c) => c.trim()).filter(Boolean);
|
|
49
|
+
if (cells.length >= 3) {
|
|
50
|
+
current.fields.push({
|
|
51
|
+
name: cells[0],
|
|
52
|
+
type: cells[1],
|
|
53
|
+
description: cells[2],
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (current) resources.push(toHttpResource(current));
|
|
60
|
+
return resources;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toHttpResource(parsed: {
|
|
64
|
+
name: string;
|
|
65
|
+
fields: { name: string; type: string; description: string }[];
|
|
66
|
+
}): HttpResource {
|
|
67
|
+
return {
|
|
68
|
+
name: parsed.name,
|
|
69
|
+
apiPath: `/${parsed.name}`,
|
|
70
|
+
fields: parsed.fields,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function extractHttpEndpoints(skillMd: string): HttpEndpoint[] {
|
|
75
|
+
const endpoints: HttpEndpoint[] = [];
|
|
76
|
+
const lines = skillMd.split("\n");
|
|
77
|
+
|
|
78
|
+
let inApi = false;
|
|
79
|
+
let currentHeading: string | null = null;
|
|
80
|
+
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
if (line.startsWith("## API")) {
|
|
83
|
+
inApi = true;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (inApi && line.startsWith("## ") && !line.startsWith("## API")) {
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
if (!inApi) continue;
|
|
90
|
+
|
|
91
|
+
if (line.startsWith("### ")) {
|
|
92
|
+
currentHeading = line.slice(4).trim();
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const match = line.match(/^`(GET|POST|PUT|PATCH|DELETE)\s+(\S+)`/);
|
|
97
|
+
if (match && currentHeading) {
|
|
98
|
+
const slug = currentHeading.toLowerCase().replace(/\s+/g, "-");
|
|
99
|
+
endpoints.push({
|
|
100
|
+
name: slug,
|
|
101
|
+
method: match[1] as HttpEndpoint["method"],
|
|
102
|
+
apiPath: match[2],
|
|
103
|
+
description: currentHeading,
|
|
104
|
+
});
|
|
105
|
+
currentHeading = null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return endpoints;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- RPC config factory ---
|
|
113
|
+
|
|
114
|
+
export interface RpcConfigResult {
|
|
115
|
+
resources: RpcResource[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Rebuild RpcResource[] from the serializable RpcSourceMeta stored in the DB.
|
|
120
|
+
* The `params` callbacks are recreated based on the convention mode.
|
|
121
|
+
*/
|
|
122
|
+
export function skillToRpcConfig(meta: RpcSourceMeta): RpcConfigResult {
|
|
123
|
+
const resources: RpcResource[] = meta.resources.map((r) => {
|
|
124
|
+
const methods: RpcResource["methods"] = {};
|
|
125
|
+
const builder = getParamsBuilder(meta.convention);
|
|
126
|
+
|
|
127
|
+
for (const [fsOp, rpcMethod] of Object.entries(r.methods)) {
|
|
128
|
+
const key = fsOp as keyof RpcResource["methods"];
|
|
129
|
+
methods[key] = {
|
|
130
|
+
method: rpcMethod,
|
|
131
|
+
params: builder(key),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const resource: RpcResource = {
|
|
136
|
+
name: r.name,
|
|
137
|
+
...(r.idField ? { idField: r.idField } : {}),
|
|
138
|
+
methods,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Add transforms for evm convention
|
|
142
|
+
if (meta.convention === "evm") {
|
|
143
|
+
resource.transform = buildEvmTransforms(r.name);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return resource;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return { resources };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
type ParamsBuilder = (fsOp: keyof RpcResource["methods"]) => RpcMethod["params"];
|
|
153
|
+
|
|
154
|
+
function getParamsBuilder(convention: RpcSourceMeta["convention"]): ParamsBuilder {
|
|
155
|
+
switch (convention) {
|
|
156
|
+
case "crud":
|
|
157
|
+
return crudParamsBuilder;
|
|
158
|
+
case "evm":
|
|
159
|
+
return evmParamsBuilder;
|
|
160
|
+
case "raw":
|
|
161
|
+
default:
|
|
162
|
+
return rawParamsBuilder;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Raw convention: pass-through — no params transformation */
|
|
167
|
+
function rawParamsBuilder(_fsOp: keyof RpcResource["methods"]): RpcMethod["params"] {
|
|
168
|
+
return (ctx: RpcCallContext) => {
|
|
169
|
+
if (ctx.data !== undefined) return [ctx.data];
|
|
170
|
+
if (ctx.id !== undefined) return [ctx.id];
|
|
171
|
+
return [];
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** CRUD convention: standard CRUD parameter mapping */
|
|
176
|
+
function crudParamsBuilder(fsOp: keyof RpcResource["methods"]): RpcMethod["params"] {
|
|
177
|
+
switch (fsOp) {
|
|
178
|
+
case "list":
|
|
179
|
+
return () => [];
|
|
180
|
+
case "read":
|
|
181
|
+
return (ctx: RpcCallContext) => [ctx.id!];
|
|
182
|
+
case "write":
|
|
183
|
+
return (ctx: RpcCallContext) => [ctx.id!, ctx.data];
|
|
184
|
+
case "create":
|
|
185
|
+
return (ctx: RpcCallContext) => [ctx.data];
|
|
186
|
+
case "remove":
|
|
187
|
+
return (ctx: RpcCallContext) => [ctx.id!];
|
|
188
|
+
case "search":
|
|
189
|
+
return (ctx: RpcCallContext) => [ctx.pattern!];
|
|
190
|
+
default:
|
|
191
|
+
return () => [];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** EVM convention: hex encoding and EVM-specific parameter patterns */
|
|
196
|
+
function evmParamsBuilder(fsOp: keyof RpcResource["methods"]): RpcMethod["params"] {
|
|
197
|
+
switch (fsOp) {
|
|
198
|
+
case "list":
|
|
199
|
+
return () => [];
|
|
200
|
+
case "read":
|
|
201
|
+
return (ctx: RpcCallContext) => {
|
|
202
|
+
const id = ctx.id!;
|
|
203
|
+
// If it looks like a number, convert to hex
|
|
204
|
+
const hexId = /^\d+$/.test(id) ? "0x" + Number(id).toString(16) : id;
|
|
205
|
+
// Second param: "latest" for address-based methods (balance, nonce, code),
|
|
206
|
+
// false for block/tx methods. "latest" is safe for both — providers that
|
|
207
|
+
// expect a boolean will coerce the truthy string, and address-based
|
|
208
|
+
// methods use it as a block tag.
|
|
209
|
+
return [hexId, "latest"];
|
|
210
|
+
};
|
|
211
|
+
default:
|
|
212
|
+
return rawParamsBuilder(fsOp);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function buildEvmTransforms(resourceName: string): RpcResource["transform"] {
|
|
217
|
+
const transform: RpcResource["transform"] = {};
|
|
218
|
+
|
|
219
|
+
if (resourceName === "blocks") {
|
|
220
|
+
// eth_blockNumber returns a hex string like "0x1234abc"
|
|
221
|
+
// Convert to a list of recent block numbers as .json files
|
|
222
|
+
transform.list = (data: unknown) => {
|
|
223
|
+
const hex = String(data);
|
|
224
|
+
const latest = parseInt(hex, 16);
|
|
225
|
+
if (isNaN(latest)) return [];
|
|
226
|
+
return Array.from({ length: 10 }, (_, i) => `${latest - i}.json`);
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (resourceName === "balances") {
|
|
231
|
+
transform.read = (data: unknown) => {
|
|
232
|
+
// Convert wei hex to a readable object
|
|
233
|
+
const hex = String(data);
|
|
234
|
+
return { wei: hex, raw: hex };
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return Object.keys(transform).length > 0 ? transform : undefined;
|
|
239
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { RegistryStore, ServiceRecord } from "./types.js";
|
|
2
|
+
import { fetchAndCompile } from "./openapi-compiler.js";
|
|
3
|
+
|
|
4
|
+
export class SourceRefresher {
|
|
5
|
+
constructor(
|
|
6
|
+
private store: RegistryStore,
|
|
7
|
+
private fetchFn: typeof globalThis.fetch = globalThis.fetch.bind(globalThis),
|
|
8
|
+
) {}
|
|
9
|
+
|
|
10
|
+
async shouldRefresh(record: ServiceRecord): Promise<boolean> {
|
|
11
|
+
if (!record.source?.refreshInterval) return false;
|
|
12
|
+
const lastRefresh = record.source.lastRefresh ?? record.updatedAt;
|
|
13
|
+
return Date.now() - lastRefresh > record.source.refreshInterval * 1000;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async refresh(record: ServiceRecord): Promise<ServiceRecord | null> {
|
|
17
|
+
if (!record.source) return null;
|
|
18
|
+
|
|
19
|
+
if (record.source.type === "openapi" && record.source.url) {
|
|
20
|
+
const result = await fetchAndCompile(
|
|
21
|
+
record.source.url,
|
|
22
|
+
{ domain: record.domain, version: record.version, isFirstParty: record.isFirstParty },
|
|
23
|
+
this.fetchFn,
|
|
24
|
+
);
|
|
25
|
+
const updated: ServiceRecord = {
|
|
26
|
+
...result.record,
|
|
27
|
+
createdAt: record.createdAt,
|
|
28
|
+
isDefault: record.isDefault,
|
|
29
|
+
source: { ...record.source, lastRefresh: Date.now() },
|
|
30
|
+
};
|
|
31
|
+
await this.store.put(record.domain, updated);
|
|
32
|
+
return updated;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (record.source.type === "wellknown" && record.source.url) {
|
|
36
|
+
const resp = await this.fetchFn(record.source.url);
|
|
37
|
+
if (!resp.ok) return null;
|
|
38
|
+
const skillMd = await resp.text();
|
|
39
|
+
// Re-parse using dynamic import to avoid circular dep
|
|
40
|
+
const { parseSkillMd } = await import("./skill-parser.js");
|
|
41
|
+
const updated = parseSkillMd(record.domain, skillMd, { isFirstParty: record.isFirstParty });
|
|
42
|
+
updated.createdAt = record.createdAt;
|
|
43
|
+
updated.isDefault = record.isDefault;
|
|
44
|
+
updated.version = record.version;
|
|
45
|
+
updated.source = { ...record.source, lastRefresh: Date.now() };
|
|
46
|
+
await this.store.put(record.domain, updated);
|
|
47
|
+
return updated;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// JSON-RPC: method lists are static, just update timestamp
|
|
51
|
+
if (record.source.type === "jsonrpc") {
|
|
52
|
+
const updated: ServiceRecord = {
|
|
53
|
+
...record,
|
|
54
|
+
source: { ...record.source, lastRefresh: Date.now() },
|
|
55
|
+
};
|
|
56
|
+
await this.store.put(record.domain, updated);
|
|
57
|
+
return updated;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async refreshAll(): Promise<{ refreshed: string[]; errors: string[] }> {
|
|
64
|
+
const services = await this.store.list();
|
|
65
|
+
const refreshed: string[] = [];
|
|
66
|
+
const errors: string[] = [];
|
|
67
|
+
|
|
68
|
+
for (const summary of services) {
|
|
69
|
+
const record = await this.store.get(summary.domain);
|
|
70
|
+
if (!record) continue;
|
|
71
|
+
if (!(await this.shouldRefresh(record))) continue;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const updated = await this.refresh(record);
|
|
75
|
+
if (updated) refreshed.push(record.domain);
|
|
76
|
+
} catch {
|
|
77
|
+
errors.push(record.domain);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { refreshed, errors };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// packages/gateway/src/registry/types.ts
|
|
2
|
+
|
|
3
|
+
export type ServiceStatus = "active" | "deprecated" | "sunset";
|
|
4
|
+
|
|
5
|
+
export interface EndpointPricing {
|
|
6
|
+
cost: number;
|
|
7
|
+
currency: string;
|
|
8
|
+
per: "call" | "byte" | "minute";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface EndpointAnnotations {
|
|
12
|
+
rateLimit?: number;
|
|
13
|
+
cacheTtl?: number;
|
|
14
|
+
tags?: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface EndpointParam {
|
|
18
|
+
name: string;
|
|
19
|
+
in: "path" | "query" | "header";
|
|
20
|
+
required: boolean;
|
|
21
|
+
type: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SchemaProperty {
|
|
26
|
+
name: string;
|
|
27
|
+
type: string;
|
|
28
|
+
required: boolean;
|
|
29
|
+
description?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface EndpointRecord {
|
|
33
|
+
method: string;
|
|
34
|
+
path: string;
|
|
35
|
+
description: string;
|
|
36
|
+
price?: string;
|
|
37
|
+
pricing?: EndpointPricing;
|
|
38
|
+
annotations?: EndpointAnnotations;
|
|
39
|
+
parameters?: EndpointParam[];
|
|
40
|
+
requestBody?: {
|
|
41
|
+
contentType: string;
|
|
42
|
+
required: boolean;
|
|
43
|
+
properties: SchemaProperty[];
|
|
44
|
+
};
|
|
45
|
+
responses?: {
|
|
46
|
+
status: number;
|
|
47
|
+
description: string;
|
|
48
|
+
properties?: SchemaProperty[];
|
|
49
|
+
}[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @deprecated Use EndpointRecord instead */
|
|
53
|
+
export type EndpointSummary = EndpointRecord;
|
|
54
|
+
|
|
55
|
+
export interface RpcSourceMeta {
|
|
56
|
+
rpcUrl: string;
|
|
57
|
+
convention: "crud" | "evm" | "raw";
|
|
58
|
+
resources: Array<{
|
|
59
|
+
name: string;
|
|
60
|
+
idField?: string;
|
|
61
|
+
methods: Record<string, string>; // fsOp → rpcMethod
|
|
62
|
+
}>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SourceConfig {
|
|
66
|
+
type: "skillmd" | "openapi" | "wellknown" | "jsonrpc";
|
|
67
|
+
url?: string;
|
|
68
|
+
basePath?: string;
|
|
69
|
+
refreshInterval?: number;
|
|
70
|
+
lastRefresh?: number;
|
|
71
|
+
rpc?: RpcSourceMeta;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ServiceRecord {
|
|
75
|
+
domain: string;
|
|
76
|
+
name: string;
|
|
77
|
+
description: string;
|
|
78
|
+
version: string;
|
|
79
|
+
roles: string[];
|
|
80
|
+
skillMd: string;
|
|
81
|
+
endpoints: EndpointRecord[];
|
|
82
|
+
isFirstParty: boolean;
|
|
83
|
+
createdAt: number;
|
|
84
|
+
updatedAt: number;
|
|
85
|
+
status: ServiceStatus;
|
|
86
|
+
isDefault: boolean;
|
|
87
|
+
sunsetDate?: number;
|
|
88
|
+
source?: SourceConfig;
|
|
89
|
+
authMode?: "nkmc-jwt";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ServiceSummary {
|
|
93
|
+
domain: string;
|
|
94
|
+
name: string;
|
|
95
|
+
description: string;
|
|
96
|
+
isFirstParty: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface SearchResult {
|
|
100
|
+
domain: string;
|
|
101
|
+
name: string;
|
|
102
|
+
description: string;
|
|
103
|
+
isFirstParty: boolean;
|
|
104
|
+
matchedEndpoints: Pick<EndpointRecord, "method" | "path" | "description">[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface VersionSummary {
|
|
108
|
+
version: string;
|
|
109
|
+
status: ServiceStatus;
|
|
110
|
+
isDefault: boolean;
|
|
111
|
+
createdAt: number;
|
|
112
|
+
updatedAt: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface RegistryStats {
|
|
116
|
+
serviceCount: number;
|
|
117
|
+
endpointCount: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface RegistryStore {
|
|
121
|
+
get(domain: string): Promise<ServiceRecord | null>;
|
|
122
|
+
getVersion(domain: string, version: string): Promise<ServiceRecord | null>;
|
|
123
|
+
listVersions(domain: string): Promise<VersionSummary[]>;
|
|
124
|
+
put(domain: string, record: ServiceRecord): Promise<void>;
|
|
125
|
+
delete(domain: string): Promise<void>;
|
|
126
|
+
list(): Promise<ServiceSummary[]>;
|
|
127
|
+
search(query: string): Promise<SearchResult[]>;
|
|
128
|
+
stats?(): Promise<RegistryStats>;
|
|
129
|
+
}
|