@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.
Files changed (130) hide show
  1. package/dist/chunk-56RA53VS.js +37 -0
  2. package/dist/chunk-CZJ75YTV.js +969 -0
  3. package/dist/chunk-QGM4M3NI.js +37 -0
  4. package/dist/http.cjs +1772 -0
  5. package/dist/http.d.cts +49 -0
  6. package/dist/http.d.ts +49 -0
  7. package/dist/http.js +748 -0
  8. package/dist/index.cjs +2436 -0
  9. package/dist/index.d.cts +436 -0
  10. package/dist/index.d.ts +436 -0
  11. package/dist/index.js +1434 -0
  12. package/dist/proxy-ClPcDgsO.d.cts +283 -0
  13. package/dist/proxy-qpda1ANS.d.ts +283 -0
  14. package/dist/proxy.cjs +148 -0
  15. package/dist/proxy.d.cts +6 -0
  16. package/dist/proxy.d.ts +6 -0
  17. package/dist/proxy.js +90 -0
  18. package/dist/testing.cjs +865 -0
  19. package/dist/testing.d.cts +12 -0
  20. package/dist/testing.d.ts +12 -0
  21. package/dist/testing.js +831 -0
  22. package/dist/tunnels-BviBEaih.d.cts +12 -0
  23. package/dist/tunnels-DFHNgmN7.d.ts +12 -0
  24. package/dist/types-C6JC9oTm.d.cts +21 -0
  25. package/dist/types-C6JC9oTm.d.ts +21 -0
  26. package/package.json +47 -0
  27. package/src/__tests__/sqlite-integration.test.ts +384 -0
  28. package/src/credential/d1-vault.ts +134 -0
  29. package/src/credential/memory-vault.ts +50 -0
  30. package/src/credential/types.ts +16 -0
  31. package/src/d1/__tests__/sqlite-adapter.test.ts +75 -0
  32. package/src/d1/sqlite-adapter.ts +59 -0
  33. package/src/d1/types.ts +22 -0
  34. package/src/federation/__tests__/d1-peer-store.test.ts +218 -0
  35. package/src/federation/__tests__/peer-client.test.ts +205 -0
  36. package/src/federation/__tests__/peer-store.test.ts +114 -0
  37. package/src/federation/d1-peer-store.ts +164 -0
  38. package/src/federation/peer-backend.ts +60 -0
  39. package/src/federation/peer-client.ts +122 -0
  40. package/src/federation/peer-store.ts +45 -0
  41. package/src/federation/types.ts +39 -0
  42. package/src/http/app.ts +152 -0
  43. package/src/http/lib/dns.ts +30 -0
  44. package/src/http/middleware/admin-auth.ts +18 -0
  45. package/src/http/middleware/agent-auth.ts +27 -0
  46. package/src/http/middleware/publish-auth.ts +39 -0
  47. package/src/http/routes/__tests__/federation.test.ts +364 -0
  48. package/src/http/routes/__tests__/peers.test.ts +290 -0
  49. package/src/http/routes/__tests__/proxy.test.ts +159 -0
  50. package/src/http/routes/auth.ts +39 -0
  51. package/src/http/routes/byok.ts +62 -0
  52. package/src/http/routes/credentials.ts +40 -0
  53. package/src/http/routes/domains.ts +174 -0
  54. package/src/http/routes/federation.ts +170 -0
  55. package/src/http/routes/fs.ts +89 -0
  56. package/src/http/routes/peers.ts +103 -0
  57. package/src/http/routes/proxy.ts +57 -0
  58. package/src/http/routes/registry.ts +222 -0
  59. package/src/http/routes/tunnels.ts +124 -0
  60. package/src/http.ts +9 -0
  61. package/src/index.ts +63 -0
  62. package/src/metering/d1-store.ts +123 -0
  63. package/src/metering/memory-store.ts +29 -0
  64. package/src/metering/pricing-guard.ts +68 -0
  65. package/src/metering/types.ts +25 -0
  66. package/src/onboard/apis-guru.ts +64 -0
  67. package/src/onboard/index.ts +4 -0
  68. package/src/onboard/manifest.ts +362 -0
  69. package/src/onboard/pipeline.ts +214 -0
  70. package/src/onboard/types.ts +72 -0
  71. package/src/proxy/__tests__/tool-registry.test.ts +93 -0
  72. package/src/proxy/tool-registry.ts +122 -0
  73. package/src/proxy.ts +12 -0
  74. package/src/registry/context7-backend.ts +93 -0
  75. package/src/registry/context7.ts +54 -0
  76. package/src/registry/d1-store.ts +242 -0
  77. package/src/registry/memory-store.ts +101 -0
  78. package/src/registry/openapi-compiler.ts +284 -0
  79. package/src/registry/resolver.ts +196 -0
  80. package/src/registry/rpc-compiler.ts +142 -0
  81. package/src/registry/skill-parser.ts +119 -0
  82. package/src/registry/skill-to-config.ts +239 -0
  83. package/src/registry/source-refresher.ts +83 -0
  84. package/src/registry/types.ts +129 -0
  85. package/src/registry/virtual-files.ts +76 -0
  86. package/src/testing/sqlite-d1.ts +64 -0
  87. package/src/testing.ts +2 -0
  88. package/src/tunnel/__tests__/cloudflare-provider.test.ts +255 -0
  89. package/src/tunnel/__tests__/tunnel.test.ts +542 -0
  90. package/src/tunnel/cloudflare-provider.ts +121 -0
  91. package/src/tunnel/memory-store.ts +30 -0
  92. package/src/tunnel/types.ts +28 -0
  93. package/test/credential/d1-vault.test.ts +127 -0
  94. package/test/credential/injection.test.ts +67 -0
  95. package/test/credential/memory-vault.test.ts +63 -0
  96. package/test/http/app.test.ts +300 -0
  97. package/test/http/byok-e2e.test.ts +240 -0
  98. package/test/http/byok.test.ts +115 -0
  99. package/test/http/credentials.test.ts +57 -0
  100. package/test/http/e2e.test.ts +260 -0
  101. package/test/integration/authenticated-apis.test.ts +185 -0
  102. package/test/integration/free-apis-e2e.test.ts +222 -0
  103. package/test/metering/d1-store.test.ts +82 -0
  104. package/test/metering/memory-store.test.ts +76 -0
  105. package/test/metering/pricing-guard.test.ts +108 -0
  106. package/test/onboard/apis-guru.test.ts +57 -0
  107. package/test/onboard/e2e.test.ts +70 -0
  108. package/test/onboard/pipeline.test.ts +318 -0
  109. package/test/onboard/real-apis.test.ts +483 -0
  110. package/test/registry/compilation-correctness.test.ts +132 -0
  111. package/test/registry/context7-backend.test.ts +88 -0
  112. package/test/registry/context7-e2e.test.ts +92 -0
  113. package/test/registry/context7.test.ts +73 -0
  114. package/test/registry/d1-store.test.ts +184 -0
  115. package/test/registry/integration.test.ts +129 -0
  116. package/test/registry/lazy-mount.test.ts +138 -0
  117. package/test/registry/memory-store.test.ts +171 -0
  118. package/test/registry/openapi-compiler.test.ts +267 -0
  119. package/test/registry/openapi-e2e.test.ts +154 -0
  120. package/test/registry/passthrough-e2e.test.ts +109 -0
  121. package/test/registry/resolver-peer.test.ts +299 -0
  122. package/test/registry/resolver.test.ts +228 -0
  123. package/test/registry/rpc-compiler.test.ts +112 -0
  124. package/test/registry/skill-parser.test.ts +151 -0
  125. package/test/registry/skill-to-config.test.ts +151 -0
  126. package/test/registry/skill-to-rpc-config.test.ts +142 -0
  127. package/test/registry/source-refresher.test.ts +90 -0
  128. package/test/registry/virtual-files.test.ts +96 -0
  129. package/tsconfig.json +4 -0
  130. package/tsup.config.ts +8 -0
@@ -0,0 +1,72 @@
1
+ import type { HttpAuth } from "@nkmc/agent-fs";
2
+
3
+ export interface RpcManifestDef {
4
+ url: string;
5
+ convention?: "crud" | "evm" | "raw";
6
+ methods: Array<{
7
+ rpcMethod: string;
8
+ description: string;
9
+ resource?: string;
10
+ fsOp?: "list" | "read" | "write" | "create" | "remove" | "search";
11
+ }>;
12
+ }
13
+
14
+ /** A single service to onboard */
15
+ export interface ManifestEntry {
16
+ domain: string;
17
+ /** OpenAPI spec URL — triggers compilation */
18
+ specUrl?: string;
19
+ /** skill.md URL — fetched and registered directly */
20
+ skillMdUrl?: string;
21
+ /** Inline skill.md content */
22
+ skillMd?: string;
23
+ /** JSON-RPC definition — triggers RPC compilation */
24
+ rpcDef?: RpcManifestDef;
25
+ /** Pool credential — values can be "${ENV_VAR}" references */
26
+ auth?: ManifestAuth;
27
+ /** Tags for categorization */
28
+ tags?: string[];
29
+ /** Skip this entry (default false) */
30
+ disabled?: boolean;
31
+ }
32
+
33
+ export interface ManifestAuth {
34
+ type: "bearer" | "api-key" | "basic" | "oauth2";
35
+ token?: string;
36
+ prefix?: string;
37
+ header?: string;
38
+ key?: string;
39
+ username?: string;
40
+ password?: string;
41
+ tokenUrl?: string;
42
+ clientId?: string;
43
+ clientSecret?: string;
44
+ scope?: string;
45
+ }
46
+
47
+ /** Result of onboarding one service */
48
+ export interface OnboardResult {
49
+ domain: string;
50
+ status: "ok" | "failed" | "skipped";
51
+ error?: string;
52
+ source: "openapi" | "skillmd" | "wellknown" | "jsonrpc" | "none";
53
+ endpoints: number;
54
+ resources: number;
55
+ hasCredentials: boolean;
56
+ smokeTest?: {
57
+ ls: boolean;
58
+ cat: boolean;
59
+ catEndpoint?: string;
60
+ };
61
+ durationMs: number;
62
+ }
63
+
64
+ /** Summary of a batch onboard run */
65
+ export interface OnboardReport {
66
+ total: number;
67
+ ok: number;
68
+ failed: number;
69
+ skipped: number;
70
+ results: OnboardResult[];
71
+ durationMs: number;
72
+ }
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { HttpAuth } from "@nkmc/agent-fs";
3
+ import {
4
+ ToolRegistry,
5
+ createDefaultToolRegistry,
6
+ } from "../tool-registry.js";
7
+
8
+ describe("ToolRegistry", () => {
9
+ it("resolves a known tool", () => {
10
+ const registry = createDefaultToolRegistry();
11
+ const gh = registry.get("gh");
12
+
13
+ expect(gh).not.toBeNull();
14
+ expect(gh!.name).toBe("gh");
15
+ expect(gh!.credentialDomain).toBe("github.com");
16
+ });
17
+
18
+ it("returns null for an unknown tool", () => {
19
+ const registry = createDefaultToolRegistry();
20
+ expect(registry.get("nonexistent")).toBeNull();
21
+ });
22
+
23
+ it("lists all registered tools", () => {
24
+ const registry = createDefaultToolRegistry();
25
+ const tools = registry.list();
26
+
27
+ expect(tools.length).toBe(5);
28
+
29
+ const names = tools.map((t) => t.name).sort();
30
+ expect(names).toEqual(["anthropic", "aws", "gh", "openai", "stripe"]);
31
+ });
32
+
33
+ it("builds env vars from bearer credential (gh → GH_TOKEN)", () => {
34
+ const registry = createDefaultToolRegistry();
35
+ const gh = registry.get("gh")!;
36
+
37
+ const auth: HttpAuth = { type: "bearer", token: "ghp_abc123" };
38
+ const env = registry.buildEnv(gh, auth);
39
+
40
+ expect(env).toEqual({ GH_TOKEN: "ghp_abc123" });
41
+ });
42
+
43
+ it("builds env vars for api-key auth type (stripe → STRIPE_API_KEY)", () => {
44
+ const registry = createDefaultToolRegistry();
45
+ const stripe = registry.get("stripe")!;
46
+
47
+ const auth: HttpAuth = {
48
+ type: "api-key",
49
+ header: "Authorization",
50
+ key: "sk_test_xyz",
51
+ };
52
+ const env = registry.buildEnv(stripe, auth);
53
+
54
+ expect(env).toEqual({ STRIPE_API_KEY: "sk_test_xyz" });
55
+ });
56
+
57
+ it("builds env vars for basic auth (aws → ACCESS_KEY_ID + SECRET)", () => {
58
+ const registry = createDefaultToolRegistry();
59
+ const aws = registry.get("aws")!;
60
+
61
+ const auth: HttpAuth = {
62
+ type: "basic",
63
+ username: "AKIAIOSFODNN7EXAMPLE",
64
+ password: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
65
+ };
66
+ const env = registry.buildEnv(aws, auth);
67
+
68
+ expect(env).toEqual({
69
+ AWS_ACCESS_KEY_ID: "AKIAIOSFODNN7EXAMPLE",
70
+ AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
71
+ });
72
+ });
73
+
74
+ it("omits env vars when auth type does not match requested field", () => {
75
+ const registry = new ToolRegistry();
76
+ registry.register({
77
+ name: "test",
78
+ credentialDomain: "example.com",
79
+ envMapping: { MY_TOKEN: "token" },
80
+ });
81
+ const tool = registry.get("test")!;
82
+
83
+ // Pass basic auth but tool wants "token" → should get empty env
84
+ const auth: HttpAuth = {
85
+ type: "basic",
86
+ username: "user",
87
+ password: "pass",
88
+ };
89
+ const env = registry.buildEnv(tool, auth);
90
+
91
+ expect(env).toEqual({});
92
+ });
93
+ });
@@ -0,0 +1,122 @@
1
+ import type { HttpAuth } from "@nkmc/agent-fs";
2
+
3
+ /** Field names that can be extracted from an HttpAuth credential. */
4
+ export type AuthField = "token" | "key" | "username" | "password";
5
+
6
+ /**
7
+ * Defines how a CLI tool maps to a credential domain and which
8
+ * environment variables should be injected at runtime.
9
+ */
10
+ export interface ToolDefinition {
11
+ /** CLI tool name, e.g. "gh", "stripe" */
12
+ name: string;
13
+ /** Domain used to look up credentials in the vault */
14
+ credentialDomain: string;
15
+ /** Maps env var name → field to extract from HttpAuth */
16
+ envMapping: Record<string, AuthField>;
17
+ }
18
+
19
+ /**
20
+ * Registry of CLI tools that can be proxied through the gateway.
21
+ * Each tool declares the credential domain it needs and how to
22
+ * translate stored credentials into environment variables.
23
+ */
24
+ export class ToolRegistry {
25
+ private tools = new Map<string, ToolDefinition>();
26
+
27
+ /** Register a tool definition. Overwrites any existing entry with the same name. */
28
+ register(tool: ToolDefinition): void {
29
+ this.tools.set(tool.name, tool);
30
+ }
31
+
32
+ /** Look up a tool by name. Returns null if not found. */
33
+ get(name: string): ToolDefinition | null {
34
+ return this.tools.get(name) ?? null;
35
+ }
36
+
37
+ /** Return all registered tool definitions. */
38
+ list(): ToolDefinition[] {
39
+ return [...this.tools.values()];
40
+ }
41
+
42
+ /**
43
+ * Build a record of environment variables for the given tool by
44
+ * extracting the requested fields from the HttpAuth credential.
45
+ *
46
+ * If the auth type does not contain the requested field (e.g.
47
+ * requesting "token" from a basic-auth credential), that env var
48
+ * is silently omitted.
49
+ */
50
+ buildEnv(tool: ToolDefinition, auth: HttpAuth): Record<string, string> {
51
+ const env: Record<string, string> = {};
52
+
53
+ for (const [envVar, field] of Object.entries(tool.envMapping)) {
54
+ const value = extractField(auth, field);
55
+ if (value !== undefined) {
56
+ env[envVar] = value;
57
+ }
58
+ }
59
+
60
+ return env;
61
+ }
62
+ }
63
+
64
+ /** Extract a named field from an HttpAuth credential. */
65
+ function extractField(auth: HttpAuth, field: AuthField): string | undefined {
66
+ switch (field) {
67
+ case "token":
68
+ return auth.type === "bearer" ? auth.token : undefined;
69
+ case "key":
70
+ return auth.type === "api-key" ? auth.key : undefined;
71
+ case "username":
72
+ return auth.type === "basic" ? auth.username : undefined;
73
+ case "password":
74
+ return auth.type === "basic" ? auth.password : undefined;
75
+ default:
76
+ return undefined;
77
+ }
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Default tools
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /** Create a ToolRegistry pre-populated with common CLI tools. */
85
+ export function createDefaultToolRegistry(): ToolRegistry {
86
+ const registry = new ToolRegistry();
87
+
88
+ registry.register({
89
+ name: "gh",
90
+ credentialDomain: "github.com",
91
+ envMapping: { GH_TOKEN: "token" },
92
+ });
93
+
94
+ registry.register({
95
+ name: "stripe",
96
+ credentialDomain: "api.stripe.com",
97
+ envMapping: { STRIPE_API_KEY: "key" },
98
+ });
99
+
100
+ registry.register({
101
+ name: "openai",
102
+ credentialDomain: "api.openai.com",
103
+ envMapping: { OPENAI_API_KEY: "key" },
104
+ });
105
+
106
+ registry.register({
107
+ name: "anthropic",
108
+ credentialDomain: "api.anthropic.com",
109
+ envMapping: { ANTHROPIC_API_KEY: "key" },
110
+ });
111
+
112
+ registry.register({
113
+ name: "aws",
114
+ credentialDomain: "aws.amazon.com",
115
+ envMapping: {
116
+ AWS_ACCESS_KEY_ID: "username",
117
+ AWS_SECRET_ACCESS_KEY: "password",
118
+ },
119
+ });
120
+
121
+ return registry;
122
+ }
package/src/proxy.ts ADDED
@@ -0,0 +1,12 @@
1
+ export {
2
+ ToolRegistry,
3
+ createDefaultToolRegistry,
4
+ type ToolDefinition,
5
+ type AuthField,
6
+ } from "./proxy/tool-registry.js";
7
+
8
+ export {
9
+ proxyRoutes,
10
+ type ProxyRouteOptions,
11
+ type ExecResult,
12
+ } from "./http/routes/proxy.js";
@@ -0,0 +1,93 @@
1
+ import type { FsBackend } from "@nkmc/agent-fs";
2
+ import { Context7Client, type Context7Options, type LibrarySearchResult } from "./context7.js";
3
+
4
+ export interface Context7BackendOptions extends Context7Options {}
5
+
6
+ /**
7
+ * FsBackend that maps filesystem operations to Context7 documentation queries.
8
+ *
9
+ * Filesystem mapping:
10
+ * grep "react" / → searchLibraries("react") — search for libraries
11
+ * cat /{owner}/{repo} → queryDocs("/{owner}/{repo}", repo) — library overview
12
+ * grep "hooks" /{o}/{r} → queryDocs("/{o}/{r}", "hooks") — query specific docs
13
+ * ls / → usage instructions
14
+ */
15
+ export class Context7Backend implements FsBackend {
16
+ private client: Context7Client;
17
+
18
+ constructor(options?: Context7BackendOptions) {
19
+ this.client = new Context7Client(options);
20
+ }
21
+
22
+ async list(path: string): Promise<string[]> {
23
+ const cleaned = path.replace(/^\/+/, "").replace(/\/+$/, "");
24
+
25
+ if (!cleaned) {
26
+ return [
27
+ 'grep "<关键词>" /context7/ — 搜索库',
28
+ 'grep "<问题>" /context7/{id} — 查询文档',
29
+ 'cat /context7/{owner}/{repo} — 库概览',
30
+ ];
31
+ }
32
+
33
+ // ls /{owner}/{repo}/ — not much to list, return hint
34
+ return ['grep "<问题>" /context7/' + cleaned + " — 查询此库文档"];
35
+ }
36
+
37
+ async read(path: string): Promise<unknown> {
38
+ const libraryId = parseLibraryId(path);
39
+ if (!libraryId) {
40
+ return { usage: 'grep "<关键词>" /context7/ — 搜索库' };
41
+ }
42
+
43
+ // cat /{owner}/{repo} → query overview
44
+ const name = libraryId.split("/").pop() ?? libraryId;
45
+ const docs = await this.client.queryDocs(libraryId, `${name} overview getting started`);
46
+ return { libraryId, docs };
47
+ }
48
+
49
+ async write(_path: string, _data: unknown): Promise<{ id: string }> {
50
+ throw new Error("context7 is read-only");
51
+ }
52
+
53
+ async remove(_path: string): Promise<void> {
54
+ throw new Error("context7 is read-only");
55
+ }
56
+
57
+ async search(path: string, pattern: string): Promise<unknown[]> {
58
+ const cleaned = path.replace(/^\/+/, "").replace(/\/+$/, "");
59
+
60
+ if (!cleaned) {
61
+ // grep at root → search libraries
62
+ const results = await this.client.searchLibraries(pattern);
63
+ return results.map(formatSearchResult);
64
+ }
65
+
66
+ // grep at /{owner}/{repo} → query docs
67
+ const libraryId = parseLibraryId(path);
68
+ if (!libraryId) return [];
69
+
70
+ const docs = await this.client.queryDocs(libraryId, pattern);
71
+ if (!docs) return [];
72
+ return [{ libraryId, query: pattern, docs }];
73
+ }
74
+ }
75
+
76
+ function parseLibraryId(path: string): string | null {
77
+ const cleaned = path.replace(/^\/+/, "").replace(/\/+$/, "");
78
+ if (!cleaned) return null;
79
+
80
+ // Expect owner/repo format
81
+ const parts = cleaned.split("/");
82
+ if (parts.length < 2) return null;
83
+ return "/" + parts.slice(0, 2).join("/");
84
+ }
85
+
86
+ function formatSearchResult(r: LibrarySearchResult): Record<string, unknown> {
87
+ return {
88
+ id: r.id,
89
+ name: r.name,
90
+ description: r.description ?? "",
91
+ snippets: r.totalSnippets ?? 0,
92
+ };
93
+ }
@@ -0,0 +1,54 @@
1
+ export interface Context7Options {
2
+ apiKey?: string;
3
+ baseUrl?: string;
4
+ fetchFn?: typeof globalThis.fetch;
5
+ }
6
+
7
+ export interface LibrarySearchResult {
8
+ id: string;
9
+ name: string;
10
+ description?: string;
11
+ totalSnippets?: number;
12
+ trustScore?: number;
13
+ }
14
+
15
+ export class Context7Client {
16
+ private apiKey?: string;
17
+ private baseUrl: string;
18
+ private fetchFn: typeof globalThis.fetch;
19
+
20
+ constructor(options?: Context7Options) {
21
+ this.apiKey = options?.apiKey;
22
+ this.baseUrl = options?.baseUrl ?? "https://context7.com/api/v2";
23
+ this.fetchFn = options?.fetchFn ?? globalThis.fetch.bind(globalThis);
24
+ }
25
+
26
+ /** Search for a library by name. Returns matching library entries. */
27
+ async searchLibraries(libraryName: string, query?: string): Promise<LibrarySearchResult[]> {
28
+ const params = new URLSearchParams({ libraryName });
29
+ if (query) params.set("query", query);
30
+
31
+ const resp = await this.fetchFn(`${this.baseUrl}/libs/search?${params}`, {
32
+ headers: this.headers(),
33
+ });
34
+ if (!resp.ok) throw new Error(`Context7 search failed: ${resp.status}`);
35
+ return resp.json() as Promise<LibrarySearchResult[]>;
36
+ }
37
+
38
+ /** Query documentation for a specific library. Returns documentation text. */
39
+ async queryDocs(libraryId: string, query: string): Promise<string> {
40
+ const params = new URLSearchParams({ libraryId, query, type: "txt" });
41
+
42
+ const resp = await this.fetchFn(`${this.baseUrl}/context?${params}`, {
43
+ headers: this.headers(),
44
+ });
45
+ if (!resp.ok) throw new Error(`Context7 query failed: ${resp.status}`);
46
+ return resp.text();
47
+ }
48
+
49
+ private headers(): Record<string, string> {
50
+ const h: Record<string, string> = {};
51
+ if (this.apiKey) h["Authorization"] = `Bearer ${this.apiKey}`;
52
+ return h;
53
+ }
54
+ }
@@ -0,0 +1,242 @@
1
+ import type { D1Database } from "../d1/types.js";
2
+ import type {
3
+ RegistryStore,
4
+ RegistryStats,
5
+ SearchResult,
6
+ ServiceRecord,
7
+ ServiceSummary,
8
+ VersionSummary,
9
+ } from "./types.js";
10
+
11
+ const CREATE_SERVICES = `
12
+ CREATE TABLE IF NOT EXISTS services (
13
+ domain TEXT NOT NULL,
14
+ version TEXT NOT NULL,
15
+ name TEXT NOT NULL,
16
+ description TEXT,
17
+ roles TEXT,
18
+ skill_md TEXT NOT NULL,
19
+ endpoints TEXT,
20
+ is_first_party INTEGER DEFAULT 0,
21
+ status TEXT DEFAULT 'active',
22
+ is_default INTEGER DEFAULT 1,
23
+ source TEXT,
24
+ sunset_date INTEGER,
25
+ auth_mode TEXT,
26
+ created_at INTEGER NOT NULL,
27
+ updated_at INTEGER NOT NULL,
28
+ PRIMARY KEY (domain, version)
29
+ )`;
30
+
31
+ const CREATE_SERVICES_DEFAULT_INDEX = `
32
+ CREATE INDEX IF NOT EXISTS idx_services_default ON services(domain, is_default)`;
33
+
34
+ const CREATE_DOMAIN_CHALLENGES = `
35
+ CREATE TABLE IF NOT EXISTS domain_challenges (
36
+ domain TEXT PRIMARY KEY,
37
+ challenge_code TEXT NOT NULL,
38
+ status TEXT NOT NULL DEFAULT 'pending',
39
+ created_at INTEGER NOT NULL,
40
+ verified_at INTEGER,
41
+ expires_at INTEGER NOT NULL
42
+ )`;
43
+
44
+ interface ServiceRow {
45
+ domain: string;
46
+ version: string;
47
+ name: string;
48
+ description: string;
49
+ roles: string;
50
+ skill_md: string;
51
+ endpoints: string;
52
+ is_first_party: number;
53
+ status: string;
54
+ is_default: number;
55
+ source: string | null;
56
+ sunset_date: number | null;
57
+ auth_mode: string | null;
58
+ created_at: number;
59
+ updated_at: number;
60
+ }
61
+
62
+ function rowToRecord(row: ServiceRow): ServiceRecord {
63
+ return {
64
+ domain: row.domain,
65
+ name: row.name,
66
+ description: row.description,
67
+ version: row.version,
68
+ roles: JSON.parse(row.roles),
69
+ skillMd: row.skill_md,
70
+ endpoints: JSON.parse(row.endpoints),
71
+ isFirstParty: row.is_first_party === 1,
72
+ createdAt: row.created_at,
73
+ updatedAt: row.updated_at,
74
+ status: row.status as ServiceRecord["status"],
75
+ isDefault: row.is_default === 1,
76
+ ...(row.source ? { source: JSON.parse(row.source) } : {}),
77
+ ...(row.sunset_date ? { sunsetDate: row.sunset_date } : {}),
78
+ ...(row.auth_mode ? { authMode: row.auth_mode as ServiceRecord["authMode"] } : {}),
79
+ };
80
+ }
81
+
82
+ function toSummary(row: ServiceRow): ServiceSummary {
83
+ return {
84
+ domain: row.domain,
85
+ name: row.name,
86
+ description: row.description,
87
+ isFirstParty: row.is_first_party === 1,
88
+ };
89
+ }
90
+
91
+ export class D1RegistryStore implements RegistryStore {
92
+ constructor(private db: D1Database) {}
93
+
94
+ async initSchema(): Promise<void> {
95
+ await this.db.exec(CREATE_SERVICES);
96
+ await this.db.exec(CREATE_SERVICES_DEFAULT_INDEX);
97
+ await this.db.exec(CREATE_DOMAIN_CHALLENGES);
98
+ }
99
+
100
+ async get(domain: string): Promise<ServiceRecord | null> {
101
+ const row = await this.db
102
+ .prepare("SELECT * FROM services WHERE domain = ? AND is_default = 1")
103
+ .bind(domain)
104
+ .first<ServiceRow>();
105
+
106
+ return row ? rowToRecord(row) : null;
107
+ }
108
+
109
+ async getVersion(domain: string, version: string): Promise<ServiceRecord | null> {
110
+ const row = await this.db
111
+ .prepare("SELECT * FROM services WHERE domain = ? AND version = ?")
112
+ .bind(domain, version)
113
+ .first<ServiceRow>();
114
+
115
+ return row ? rowToRecord(row) : null;
116
+ }
117
+
118
+ async listVersions(domain: string): Promise<VersionSummary[]> {
119
+ const { results } = await this.db
120
+ .prepare(
121
+ "SELECT version, status, is_default, created_at, updated_at FROM services WHERE domain = ? ORDER BY created_at DESC",
122
+ )
123
+ .bind(domain)
124
+ .all<ServiceRow>();
125
+
126
+ return results.map((row) => ({
127
+ version: row.version,
128
+ status: row.status as ServiceRecord["status"],
129
+ isDefault: row.is_default === 1,
130
+ createdAt: row.created_at,
131
+ updatedAt: row.updated_at,
132
+ }));
133
+ }
134
+
135
+ /** Max endpoints JSON size before stripping verbose fields (parameters, requestBody, responses). */
136
+ static readonly ENDPOINTS_SIZE_LIMIT = 800_000; // ~800 KB, well under D1's ~1 MB per-value limit
137
+
138
+ async put(domain: string, record: ServiceRecord): Promise<void> {
139
+ let endpointsJson = JSON.stringify(record.endpoints);
140
+
141
+ // Only strip verbose fields when the full JSON exceeds the size limit.
142
+ // Small APIs keep parameters/requestBody/responses for the explore detail page.
143
+ if (endpointsJson.length > D1RegistryStore.ENDPOINTS_SIZE_LIMIT) {
144
+ const slim = record.endpoints.map(({ method, path, description, price, pricing }) => ({
145
+ method,
146
+ path,
147
+ description,
148
+ ...(price ? { price } : {}),
149
+ ...(pricing ? { pricing } : {}),
150
+ }));
151
+ endpointsJson = JSON.stringify(slim);
152
+ }
153
+
154
+ await this.db
155
+ .prepare(
156
+ `INSERT OR REPLACE INTO services
157
+ (domain, version, name, description, roles, skill_md, endpoints, is_first_party, status, is_default, source, sunset_date, auth_mode, created_at, updated_at)
158
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
159
+ )
160
+ .bind(
161
+ domain,
162
+ record.version,
163
+ record.name,
164
+ record.description,
165
+ JSON.stringify(record.roles),
166
+ record.skillMd,
167
+ endpointsJson,
168
+ record.isFirstParty ? 1 : 0,
169
+ record.status,
170
+ record.isDefault ? 1 : 0,
171
+ record.source ? JSON.stringify(record.source) : null,
172
+ record.sunsetDate ?? null,
173
+ record.authMode ?? null,
174
+ record.createdAt,
175
+ record.updatedAt,
176
+ )
177
+ .run();
178
+ }
179
+
180
+ async delete(domain: string): Promise<void> {
181
+ await this.db
182
+ .prepare("DELETE FROM services WHERE domain = ?")
183
+ .bind(domain)
184
+ .run();
185
+ }
186
+
187
+ async list(): Promise<ServiceSummary[]> {
188
+ const { results } = await this.db
189
+ .prepare("SELECT domain, name, description, is_first_party FROM services WHERE is_default = 1")
190
+ .all<ServiceRow>();
191
+
192
+ return results.map(toSummary);
193
+ }
194
+
195
+ async search(query: string): Promise<SearchResult[]> {
196
+ const pattern = `%${query}%`;
197
+ const { results: rows } = await this.db
198
+ .prepare(
199
+ `SELECT * FROM services
200
+ WHERE is_default = 1 AND (name LIKE ? OR description LIKE ? OR endpoints LIKE ?)`,
201
+ )
202
+ .bind(pattern, pattern, pattern)
203
+ .all<ServiceRow>();
204
+
205
+ const q = query.toLowerCase();
206
+ return rows.map((row) => {
207
+ const endpoints = JSON.parse(row.endpoints) as Array<{
208
+ method: string;
209
+ path: string;
210
+ description: string;
211
+ }>;
212
+ const matched = endpoints.filter(
213
+ (e) =>
214
+ e.description.toLowerCase().includes(q) ||
215
+ e.method.toLowerCase().includes(q) ||
216
+ e.path.toLowerCase().includes(q),
217
+ );
218
+ return {
219
+ ...toSummary(row),
220
+ matchedEndpoints: matched.map((e) => ({
221
+ method: e.method,
222
+ path: e.path,
223
+ description: e.description,
224
+ })),
225
+ };
226
+ });
227
+ }
228
+
229
+ async stats(): Promise<RegistryStats> {
230
+ const row = await this.db
231
+ .prepare(
232
+ `SELECT COUNT(*) as service_count, COALESCE(SUM(json_array_length(endpoints)), 0) as endpoint_count
233
+ FROM services WHERE is_default = 1`,
234
+ )
235
+ .first<{ service_count: number; endpoint_count: number }>();
236
+
237
+ return {
238
+ serviceCount: row?.service_count ?? 0,
239
+ endpointCount: row?.endpoint_count ?? 0,
240
+ };
241
+ }
242
+ }