@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,101 @@
|
|
|
1
|
+
import type { RegistryStore, SearchResult, ServiceRecord, ServiceSummary, VersionSummary } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export class MemoryRegistryStore implements RegistryStore {
|
|
4
|
+
// key = "domain@version"
|
|
5
|
+
private records = new Map<string, ServiceRecord>();
|
|
6
|
+
|
|
7
|
+
private key(domain: string, version: string): string {
|
|
8
|
+
return `${domain}@${version}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async get(domain: string): Promise<ServiceRecord | null> {
|
|
12
|
+
for (const record of this.records.values()) {
|
|
13
|
+
if (record.domain === domain && record.isDefault) return record;
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async getVersion(domain: string, version: string): Promise<ServiceRecord | null> {
|
|
19
|
+
return this.records.get(this.key(domain, version)) ?? null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async listVersions(domain: string): Promise<VersionSummary[]> {
|
|
23
|
+
const versions: VersionSummary[] = [];
|
|
24
|
+
for (const record of this.records.values()) {
|
|
25
|
+
if (record.domain === domain) {
|
|
26
|
+
versions.push({
|
|
27
|
+
version: record.version,
|
|
28
|
+
status: record.status,
|
|
29
|
+
isDefault: record.isDefault,
|
|
30
|
+
createdAt: record.createdAt,
|
|
31
|
+
updatedAt: record.updatedAt,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return versions.sort((a, b) => b.createdAt - a.createdAt);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async put(domain: string, record: ServiceRecord): Promise<void> {
|
|
39
|
+
this.records.set(this.key(domain, record.version), record);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async delete(domain: string): Promise<void> {
|
|
43
|
+
const keysToDelete: string[] = [];
|
|
44
|
+
for (const [key, record] of this.records.entries()) {
|
|
45
|
+
if (record.domain === domain) keysToDelete.push(key);
|
|
46
|
+
}
|
|
47
|
+
for (const key of keysToDelete) {
|
|
48
|
+
this.records.delete(key);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async list(): Promise<ServiceSummary[]> {
|
|
53
|
+
const results: ServiceSummary[] = [];
|
|
54
|
+
for (const record of this.records.values()) {
|
|
55
|
+
if (record.isDefault) results.push(toSummary(record));
|
|
56
|
+
}
|
|
57
|
+
return results;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async search(query: string): Promise<SearchResult[]> {
|
|
61
|
+
const q = query.toLowerCase();
|
|
62
|
+
const results: SearchResult[] = [];
|
|
63
|
+
|
|
64
|
+
for (const record of this.records.values()) {
|
|
65
|
+
if (!record.isDefault) continue;
|
|
66
|
+
|
|
67
|
+
const nameMatch =
|
|
68
|
+
record.name.toLowerCase().includes(q) ||
|
|
69
|
+
record.description.toLowerCase().includes(q);
|
|
70
|
+
|
|
71
|
+
const matched = record.endpoints.filter(
|
|
72
|
+
(e) =>
|
|
73
|
+
e.description.toLowerCase().includes(q) ||
|
|
74
|
+
e.method.toLowerCase().includes(q) ||
|
|
75
|
+
e.path.toLowerCase().includes(q),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (nameMatch || matched.length > 0) {
|
|
79
|
+
results.push({
|
|
80
|
+
...toSummary(record),
|
|
81
|
+
matchedEndpoints: matched.map((e) => ({
|
|
82
|
+
method: e.method,
|
|
83
|
+
path: e.path,
|
|
84
|
+
description: e.description,
|
|
85
|
+
})),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return results;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function toSummary(record: ServiceRecord): ServiceSummary {
|
|
95
|
+
return {
|
|
96
|
+
domain: record.domain,
|
|
97
|
+
name: record.name,
|
|
98
|
+
description: record.description,
|
|
99
|
+
isFirstParty: record.isFirstParty,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import YAML from "yaml";
|
|
2
|
+
import type { ServiceRecord, EndpointRecord, EndpointPricing, EndpointParam, SchemaProperty } from "./types.js";
|
|
3
|
+
import type { HttpResource } from "@nkmc/agent-fs";
|
|
4
|
+
|
|
5
|
+
export interface CompileOptions {
|
|
6
|
+
domain: string;
|
|
7
|
+
version?: string;
|
|
8
|
+
isFirstParty?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CompileResult {
|
|
12
|
+
record: ServiceRecord;
|
|
13
|
+
resources: HttpResource[];
|
|
14
|
+
skillMd: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Extract basePath from OpenAPI servers[0].url (e.g. "/client/v4" from "https://api.cloudflare.com/client/v4") */
|
|
18
|
+
export function extractBasePath(spec: any): string {
|
|
19
|
+
const servers = spec.servers;
|
|
20
|
+
if (!Array.isArray(servers) || servers.length === 0) return "";
|
|
21
|
+
const serverUrl = servers[0]?.url;
|
|
22
|
+
if (!serverUrl || typeof serverUrl !== "string") return "";
|
|
23
|
+
try {
|
|
24
|
+
// Handle relative URLs
|
|
25
|
+
if (serverUrl.startsWith("/")) {
|
|
26
|
+
return serverUrl.replace(/\/+$/, "");
|
|
27
|
+
}
|
|
28
|
+
const parsed = new URL(serverUrl);
|
|
29
|
+
const pathname = parsed.pathname.replace(/\/+$/, "");
|
|
30
|
+
return pathname || "";
|
|
31
|
+
} catch {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Resolve a $ref pointer like "#/components/schemas/Pet" */
|
|
37
|
+
function resolveRef(spec: any, ref: string): any {
|
|
38
|
+
if (!ref.startsWith("#/")) return undefined;
|
|
39
|
+
const parts = ref.slice(2).split("/");
|
|
40
|
+
let current = spec;
|
|
41
|
+
for (const part of parts) {
|
|
42
|
+
if (current == null || typeof current !== "object") return undefined;
|
|
43
|
+
current = current[part];
|
|
44
|
+
}
|
|
45
|
+
return current;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Resolve a schema, following $ref if present (one level only) */
|
|
49
|
+
function resolveSchema(spec: any, schema: any): any {
|
|
50
|
+
if (!schema) return undefined;
|
|
51
|
+
if (schema.$ref) return resolveRef(spec, schema.$ref);
|
|
52
|
+
return schema;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Extract top-level properties from a JSON Schema object */
|
|
56
|
+
function extractProperties(spec: any, schema: any): SchemaProperty[] {
|
|
57
|
+
const resolved = resolveSchema(spec, schema);
|
|
58
|
+
if (!resolved || resolved.type !== "object" || !resolved.properties) return [];
|
|
59
|
+
const requiredSet = new Set<string>(resolved.required ?? []);
|
|
60
|
+
const props: SchemaProperty[] = [];
|
|
61
|
+
for (const [name, prop] of Object.entries(resolved.properties as Record<string, any>)) {
|
|
62
|
+
const p = resolveSchema(spec, prop) ?? prop;
|
|
63
|
+
props.push({
|
|
64
|
+
name,
|
|
65
|
+
type: p.type ?? (p.enum ? "enum" : "unknown"),
|
|
66
|
+
required: requiredSet.has(name),
|
|
67
|
+
...(p.description ? { description: p.description } : {}),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return props;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Extract parameters from an OpenAPI operation */
|
|
74
|
+
function extractParams(spec: any, operation: any): EndpointParam[] | undefined {
|
|
75
|
+
const params = operation.parameters;
|
|
76
|
+
if (!Array.isArray(params) || params.length === 0) return undefined;
|
|
77
|
+
const result: EndpointParam[] = [];
|
|
78
|
+
for (const raw of params) {
|
|
79
|
+
const p = resolveSchema(spec, raw) ?? raw;
|
|
80
|
+
if (!p.name || !p.in) continue;
|
|
81
|
+
if (!["path", "query", "header"].includes(p.in)) continue;
|
|
82
|
+
const schema = resolveSchema(spec, p.schema) ?? p.schema;
|
|
83
|
+
result.push({
|
|
84
|
+
name: p.name,
|
|
85
|
+
in: p.in as "path" | "query" | "header",
|
|
86
|
+
required: !!p.required,
|
|
87
|
+
type: schema?.type ?? "string",
|
|
88
|
+
...(p.description ? { description: p.description } : {}),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return result.length > 0 ? result : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Extract requestBody from an OpenAPI operation */
|
|
95
|
+
function extractRequestBody(spec: any, operation: any): EndpointRecord["requestBody"] {
|
|
96
|
+
const body = resolveSchema(spec, operation.requestBody);
|
|
97
|
+
if (!body?.content) return undefined;
|
|
98
|
+
const jsonContent = body.content["application/json"];
|
|
99
|
+
if (!jsonContent?.schema) return undefined;
|
|
100
|
+
const properties = extractProperties(spec, jsonContent.schema);
|
|
101
|
+
if (properties.length === 0) return undefined;
|
|
102
|
+
return {
|
|
103
|
+
contentType: "application/json",
|
|
104
|
+
required: !!body.required,
|
|
105
|
+
properties,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Extract 2xx responses from an OpenAPI operation */
|
|
110
|
+
function extractResponses(spec: any, operation: any): EndpointRecord["responses"] {
|
|
111
|
+
const responses = operation.responses;
|
|
112
|
+
if (!responses || typeof responses !== "object") return undefined;
|
|
113
|
+
const result: NonNullable<EndpointRecord["responses"]> = [];
|
|
114
|
+
for (const [code, raw] of Object.entries(responses as Record<string, any>)) {
|
|
115
|
+
const status = parseInt(code, 10);
|
|
116
|
+
if (isNaN(status) || status < 200 || status >= 300) continue;
|
|
117
|
+
const resp = resolveSchema(spec, raw) ?? raw;
|
|
118
|
+
const jsonContent = resp?.content?.["application/json"];
|
|
119
|
+
const properties = jsonContent?.schema ? extractProperties(spec, jsonContent.schema) : undefined;
|
|
120
|
+
result.push({
|
|
121
|
+
status,
|
|
122
|
+
description: resp?.description ?? "",
|
|
123
|
+
...(properties && properties.length > 0 ? { properties } : {}),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return result.length > 0 ? result : undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Compile an OpenAPI spec (parsed JSON object) into a ServiceRecord + HttpResources
|
|
130
|
+
export function compileOpenApiSpec(spec: any, options: CompileOptions): CompileResult {
|
|
131
|
+
const info = spec.info ?? {};
|
|
132
|
+
const name = info.title ?? options.domain;
|
|
133
|
+
const description = info.description ?? "";
|
|
134
|
+
const version = options.version ?? info.version ?? "1.0";
|
|
135
|
+
const basePath = extractBasePath(spec);
|
|
136
|
+
|
|
137
|
+
const endpoints: EndpointRecord[] = [];
|
|
138
|
+
const resources: HttpResource[] = [];
|
|
139
|
+
const resourcePaths = new Map<string, HttpResource>();
|
|
140
|
+
|
|
141
|
+
// Extract endpoints from paths
|
|
142
|
+
const paths = spec.paths ?? {};
|
|
143
|
+
for (const [path, methods] of Object.entries(paths)) {
|
|
144
|
+
if (typeof methods !== "object" || methods === null) continue;
|
|
145
|
+
for (const [method, op] of Object.entries(methods as Record<string, any>)) {
|
|
146
|
+
if (!["get", "post", "put", "patch", "delete"].includes(method)) continue;
|
|
147
|
+
const operation = op as any;
|
|
148
|
+
const parameters = extractParams(spec, operation);
|
|
149
|
+
const requestBody = extractRequestBody(spec, operation);
|
|
150
|
+
const responses = extractResponses(spec, operation);
|
|
151
|
+
endpoints.push({
|
|
152
|
+
method: method.toUpperCase(),
|
|
153
|
+
path,
|
|
154
|
+
description: operation.summary ?? operation.operationId ?? `${method.toUpperCase()} ${path}`,
|
|
155
|
+
...(parameters ? { parameters } : {}),
|
|
156
|
+
...(requestBody ? { requestBody } : {}),
|
|
157
|
+
...(responses ? { responses } : {}),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Infer resources from path patterns like /resources or /resources/{id}
|
|
162
|
+
const segments = path.split("/").filter(Boolean);
|
|
163
|
+
if (segments.length >= 1) {
|
|
164
|
+
const resourceName = segments[0];
|
|
165
|
+
if (!resourcePaths.has(resourceName) && !resourceName.startsWith("{")) {
|
|
166
|
+
resourcePaths.set(resourceName, {
|
|
167
|
+
name: resourceName,
|
|
168
|
+
apiPath: `/${resourceName}`,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const r of resourcePaths.values()) {
|
|
175
|
+
resources.push(r);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Generate skill.md
|
|
179
|
+
const skillMd = generateSkillMd(name, version, description, endpoints, resources);
|
|
180
|
+
|
|
181
|
+
const now = Date.now();
|
|
182
|
+
const record: ServiceRecord = {
|
|
183
|
+
domain: options.domain,
|
|
184
|
+
name,
|
|
185
|
+
description,
|
|
186
|
+
version,
|
|
187
|
+
roles: ["agent"],
|
|
188
|
+
skillMd,
|
|
189
|
+
endpoints,
|
|
190
|
+
isFirstParty: options.isFirstParty ?? false,
|
|
191
|
+
createdAt: now,
|
|
192
|
+
updatedAt: now,
|
|
193
|
+
status: "active",
|
|
194
|
+
isDefault: true,
|
|
195
|
+
source: { type: "openapi", ...(basePath ? { basePath } : {}) },
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
return { record, resources, skillMd };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Fetch a remote OpenAPI spec and compile
|
|
202
|
+
export async function fetchAndCompile(
|
|
203
|
+
specUrl: string,
|
|
204
|
+
options: CompileOptions,
|
|
205
|
+
fetchFn: typeof globalThis.fetch = globalThis.fetch.bind(globalThis),
|
|
206
|
+
): Promise<CompileResult> {
|
|
207
|
+
const resp = await fetchFn(specUrl);
|
|
208
|
+
if (!resp.ok) throw new Error(`Failed to fetch spec: ${resp.status} ${resp.statusText}`);
|
|
209
|
+
const text = await resp.text();
|
|
210
|
+
const spec = parseSpec(specUrl, resp.headers.get("content-type") ?? "", text);
|
|
211
|
+
const result = compileOpenApiSpec(spec, options);
|
|
212
|
+
const basePath = result.record.source?.basePath;
|
|
213
|
+
result.record.source = { type: "openapi", url: specUrl, ...(basePath ? { basePath } : {}) };
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Detect format from content-type or URL extension and parse accordingly */
|
|
218
|
+
function parseSpec(url: string, contentType: string, text: string): any {
|
|
219
|
+
const isJson =
|
|
220
|
+
contentType.includes("json") ||
|
|
221
|
+
url.endsWith(".json") ||
|
|
222
|
+
text.trimStart().startsWith("{");
|
|
223
|
+
if (isJson) return JSON.parse(text);
|
|
224
|
+
return YAML.parse(text);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function propsTable(props: SchemaProperty[]): string {
|
|
228
|
+
let t = "| name | type | required |\n|------|------|----------|\n";
|
|
229
|
+
for (const p of props) {
|
|
230
|
+
t += `| ${p.name} | ${p.type} | ${p.required ? "*" : ""} |\n`;
|
|
231
|
+
}
|
|
232
|
+
return t;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function generateSkillMd(
|
|
236
|
+
name: string,
|
|
237
|
+
version: string,
|
|
238
|
+
description: string,
|
|
239
|
+
endpoints: EndpointRecord[],
|
|
240
|
+
resources: HttpResource[],
|
|
241
|
+
): string {
|
|
242
|
+
let md = `---\nname: "${name}"\ngateway: nkmc\nversion: "${version}"\nroles: [agent]\n---\n\n`;
|
|
243
|
+
md += `# ${name}\n\n${description}\n\n`;
|
|
244
|
+
if (resources.length > 0) {
|
|
245
|
+
md += `## Schema\n\n`;
|
|
246
|
+
for (const r of resources) {
|
|
247
|
+
md += `### ${r.name} (public)\n\n`;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (endpoints.length > 0) {
|
|
251
|
+
md += `## API\n\n`;
|
|
252
|
+
for (const ep of endpoints) {
|
|
253
|
+
md += `### ${ep.description}\n\n`;
|
|
254
|
+
md += `\`${ep.method} ${ep.path}\`\n\n`;
|
|
255
|
+
|
|
256
|
+
if (ep.parameters && ep.parameters.length > 0) {
|
|
257
|
+
md += "**Parameters:**\n\n";
|
|
258
|
+
md += "| name | in | type | required |\n|------|-----|------|----------|\n";
|
|
259
|
+
for (const p of ep.parameters) {
|
|
260
|
+
md += `| ${p.name} | ${p.in} | ${p.type} | ${p.required ? "*" : ""} |\n`;
|
|
261
|
+
}
|
|
262
|
+
md += "\n";
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (ep.requestBody) {
|
|
266
|
+
const req = ep.requestBody;
|
|
267
|
+
md += `**Body** (${req.contentType}${req.required ? ", required" : ""}):\n\n`;
|
|
268
|
+
md += propsTable(req.properties);
|
|
269
|
+
md += "\n";
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (ep.responses && ep.responses.length > 0) {
|
|
273
|
+
for (const r of ep.responses) {
|
|
274
|
+
md += `**Response ${r.status}**${r.description ? `: ${r.description}` : ""}\n\n`;
|
|
275
|
+
if (r.properties && r.properties.length > 0) {
|
|
276
|
+
md += propsTable(r.properties);
|
|
277
|
+
md += "\n";
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return md;
|
|
284
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { HttpBackend, RpcBackend, JsonRpcTransport } from "@nkmc/agent-fs";
|
|
2
|
+
import type { AgentContext, Mount, FsBackend, HttpAuth } from "@nkmc/agent-fs";
|
|
3
|
+
import type { JWK } from "jose";
|
|
4
|
+
import { signJwt } from "@nkmc/core";
|
|
5
|
+
import type { EndpointRecord, RegistryStore, SearchResult } from "./types.js";
|
|
6
|
+
import { skillToHttpConfig, skillToRpcConfig } from "./skill-to-config.js";
|
|
7
|
+
import { VirtualFileBackend } from "./virtual-files.js";
|
|
8
|
+
import type { PeerGateway } from "../federation/types.js";
|
|
9
|
+
import type { PeerClient } from "../federation/peer-client.js";
|
|
10
|
+
import { PeerBackend } from "../federation/peer-backend.js";
|
|
11
|
+
|
|
12
|
+
export interface RegistryResolverHooks {
|
|
13
|
+
onMiss: (path: string, addMount: (mount: Mount) => void, agent?: AgentContext) => Promise<boolean>;
|
|
14
|
+
listDomains: () => Promise<string[]>;
|
|
15
|
+
searchDomains: (query: string) => Promise<SearchResult[]>;
|
|
16
|
+
searchEndpoints: (domain: string, query: string) => Promise<Pick<EndpointRecord, "method" | "path" | "description">[]>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RegistryResolverOptions {
|
|
20
|
+
store: RegistryStore;
|
|
21
|
+
vault?: {
|
|
22
|
+
get(domain: string, developerId?: string): Promise<{ auth: HttpAuth } | null>;
|
|
23
|
+
};
|
|
24
|
+
gatewayPrivateKey?: JWK;
|
|
25
|
+
wrapVirtualFiles?: boolean;
|
|
26
|
+
peerStore?: { listPeers(): Promise<PeerGateway[]> };
|
|
27
|
+
peerClient?: PeerClient;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createRegistryResolver(
|
|
31
|
+
storeOrOptions: RegistryStore | RegistryResolverOptions,
|
|
32
|
+
): RegistryResolverHooks {
|
|
33
|
+
const options: RegistryResolverOptions =
|
|
34
|
+
"get" in storeOrOptions && "put" in storeOrOptions
|
|
35
|
+
? { store: storeOrOptions }
|
|
36
|
+
: storeOrOptions;
|
|
37
|
+
const { store, vault, gatewayPrivateKey } = options;
|
|
38
|
+
|
|
39
|
+
// cache key = "domain@version" or "domain"
|
|
40
|
+
const loaded = new Set<string>();
|
|
41
|
+
|
|
42
|
+
async function tryPeerFallback(
|
|
43
|
+
domain: string,
|
|
44
|
+
version: string | null,
|
|
45
|
+
addMount: (mount: Mount) => void,
|
|
46
|
+
agent?: AgentContext,
|
|
47
|
+
): Promise<boolean> {
|
|
48
|
+
if (!options.peerClient || !options.peerStore) return false;
|
|
49
|
+
|
|
50
|
+
const peers = await options.peerStore.listPeers();
|
|
51
|
+
for (const peer of peers) {
|
|
52
|
+
// Skip peers whose advertised domains don't include this domain
|
|
53
|
+
if (peer.advertisedDomains.length > 0 && !peer.advertisedDomains.includes(domain)) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const result = await options.peerClient.query(peer, domain);
|
|
57
|
+
if (result.available) {
|
|
58
|
+
const peerBackend = new PeerBackend(options.peerClient, peer, agent?.id ?? "anonymous");
|
|
59
|
+
const mountPath = version ? `/${domain}@${version}` : `/${domain}`;
|
|
60
|
+
addMount({ path: mountPath, backend: peerBackend });
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function onMiss(
|
|
68
|
+
path: string,
|
|
69
|
+
addMount: (mount: Mount) => void,
|
|
70
|
+
agent?: AgentContext,
|
|
71
|
+
): Promise<boolean> {
|
|
72
|
+
const { domain, version } = extractDomainPath(path);
|
|
73
|
+
if (!domain) return false;
|
|
74
|
+
|
|
75
|
+
const cacheKey = version ? `${domain}@${version}` : domain;
|
|
76
|
+
|
|
77
|
+
// Fetch record first — we need it to determine auth mode
|
|
78
|
+
const record = version
|
|
79
|
+
? await store.getVersion(domain, version)
|
|
80
|
+
: await store.get(domain);
|
|
81
|
+
|
|
82
|
+
// No local record → try peer gateways before giving up
|
|
83
|
+
if (!record) {
|
|
84
|
+
return tryPeerFallback(domain, version, addMount, agent);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const isNkmcJwt = record.authMode === "nkmc-jwt";
|
|
88
|
+
|
|
89
|
+
// For non-nkmc-jwt, use cache; nkmc-jwt needs fresh JWT per request
|
|
90
|
+
if (!isNkmcJwt && loaded.has(cacheKey)) return false;
|
|
91
|
+
|
|
92
|
+
// Reject sunset services
|
|
93
|
+
if (record.status === "sunset") return false;
|
|
94
|
+
|
|
95
|
+
// Resolve auth credentials
|
|
96
|
+
let auth: HttpAuth | undefined;
|
|
97
|
+
if (isNkmcJwt && gatewayPrivateKey && agent) {
|
|
98
|
+
const token = await signJwt(gatewayPrivateKey, {
|
|
99
|
+
sub: agent.id,
|
|
100
|
+
roles: agent.roles,
|
|
101
|
+
svc: domain,
|
|
102
|
+
}, { expiresIn: "5m" });
|
|
103
|
+
auth = { type: "bearer", token };
|
|
104
|
+
} else if (vault) {
|
|
105
|
+
const cred = await vault.get(domain, agent?.id);
|
|
106
|
+
if (cred) {
|
|
107
|
+
auth = cred.auth;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Local record exists but no credential resolved (and not nkmc-jwt) → try peers
|
|
112
|
+
if (!auth && !isNkmcJwt) {
|
|
113
|
+
const peerMounted = await tryPeerFallback(domain, version, addMount, agent);
|
|
114
|
+
if (peerMounted) return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Create backend based on source type
|
|
118
|
+
let backend: FsBackend;
|
|
119
|
+
if (record.source?.type === "jsonrpc" && record.source.rpc) {
|
|
120
|
+
const { resources } = skillToRpcConfig(record.source.rpc);
|
|
121
|
+
const headers: Record<string, string> = {};
|
|
122
|
+
if (auth) {
|
|
123
|
+
if (auth.type === "bearer") {
|
|
124
|
+
headers["Authorization"] = `${(auth as any).prefix ?? "Bearer"} ${auth.token}`;
|
|
125
|
+
} else if (auth.type === "api-key") {
|
|
126
|
+
headers[auth.header] = auth.key;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const transport = new JsonRpcTransport({ url: record.source.rpc.rpcUrl, headers });
|
|
130
|
+
backend = new RpcBackend({ transport, resources });
|
|
131
|
+
} else {
|
|
132
|
+
const config = skillToHttpConfig(record);
|
|
133
|
+
config.auth = auth;
|
|
134
|
+
backend = new HttpBackend(config);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let finalBackend: FsBackend = backend;
|
|
138
|
+
if (options.wrapVirtualFiles !== false) {
|
|
139
|
+
finalBackend = new VirtualFileBackend({ inner: backend, domain, store: options.store });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const mountPath = version ? `/${domain}@${version}` : `/${domain}`;
|
|
143
|
+
addMount({ path: mountPath, backend: finalBackend });
|
|
144
|
+
|
|
145
|
+
// Only cache non-nkmc-jwt mounts
|
|
146
|
+
if (!isNkmcJwt) {
|
|
147
|
+
loaded.add(cacheKey);
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function listDomains(): Promise<string[]> {
|
|
153
|
+
const summaries = await store.list();
|
|
154
|
+
return summaries.map((s) => s.domain);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function searchDomains(query: string): Promise<SearchResult[]> {
|
|
158
|
+
return store.search(query);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function searchEndpoints(
|
|
162
|
+
domain: string,
|
|
163
|
+
query: string,
|
|
164
|
+
): Promise<Pick<EndpointRecord, "method" | "path" | "description">[]> {
|
|
165
|
+
const record = await store.get(domain);
|
|
166
|
+
if (!record) return [];
|
|
167
|
+
const q = query.toLowerCase();
|
|
168
|
+
return record.endpoints
|
|
169
|
+
.filter(
|
|
170
|
+
(e) =>
|
|
171
|
+
e.description.toLowerCase().includes(q) ||
|
|
172
|
+
e.method.toLowerCase().includes(q) ||
|
|
173
|
+
e.path.toLowerCase().includes(q),
|
|
174
|
+
)
|
|
175
|
+
.map((e) => ({ method: e.method, path: e.path, description: e.description }));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { onMiss, listDomains, searchDomains, searchEndpoints };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function extractDomainPath(path: string): { domain: string | null; version: string | null } {
|
|
182
|
+
const segments = path.split("/").filter(Boolean);
|
|
183
|
+
if (segments.length === 0) return { domain: null, version: null };
|
|
184
|
+
|
|
185
|
+
const first = segments[0];
|
|
186
|
+
// Parse @version: "api.cloudflare.com@v5" → { domain: "api.cloudflare.com", version: "v5" }
|
|
187
|
+
const atIndex = first.indexOf("@");
|
|
188
|
+
if (atIndex > 0) {
|
|
189
|
+
return {
|
|
190
|
+
domain: first.slice(0, atIndex),
|
|
191
|
+
version: first.slice(atIndex + 1),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { domain: first, version: null };
|
|
196
|
+
}
|