@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,12 @@
1
+ import * as hono_types from 'hono/types';
2
+ import { Hono } from 'hono';
3
+ import { T as TunnelStore, a as TunnelProvider, E as Env } from './proxy-ClPcDgsO.cjs';
4
+
5
+ interface TunnelRouteOptions {
6
+ tunnelStore: TunnelStore;
7
+ tunnelProvider: TunnelProvider;
8
+ tunnelDomain: string;
9
+ }
10
+ declare function tunnelRoutes(options: TunnelRouteOptions): Hono<Env, hono_types.BlankSchema, "/">;
11
+
12
+ export { type TunnelRouteOptions as T, tunnelRoutes as t };
@@ -0,0 +1,12 @@
1
+ import * as hono_types from 'hono/types';
2
+ import { Hono } from 'hono';
3
+ import { T as TunnelStore, a as TunnelProvider, E as Env } from './proxy-qpda1ANS.js';
4
+
5
+ interface TunnelRouteOptions {
6
+ tunnelStore: TunnelStore;
7
+ tunnelProvider: TunnelProvider;
8
+ tunnelDomain: string;
9
+ }
10
+ declare function tunnelRoutes(options: TunnelRouteOptions): Hono<Env, hono_types.BlankSchema, "/">;
11
+
12
+ export { type TunnelRouteOptions as T, tunnelRoutes as t };
@@ -0,0 +1,21 @@
1
+ interface D1Result<T = Record<string, unknown>> {
2
+ results: T[];
3
+ success: boolean;
4
+ }
5
+ interface D1RunResult {
6
+ success: boolean;
7
+ changes: number;
8
+ lastRowId: number;
9
+ }
10
+ interface D1PreparedStatement {
11
+ bind(...values: unknown[]): D1PreparedStatement;
12
+ first<T = Record<string, unknown>>(): Promise<T | null>;
13
+ all<T = Record<string, unknown>>(): Promise<D1Result<T>>;
14
+ run(): Promise<D1RunResult>;
15
+ }
16
+ interface D1Database {
17
+ prepare(sql: string): D1PreparedStatement;
18
+ exec(sql: string): Promise<void>;
19
+ }
20
+
21
+ export type { D1Database as D, D1PreparedStatement as a, D1Result as b, D1RunResult as c };
@@ -0,0 +1,21 @@
1
+ interface D1Result<T = Record<string, unknown>> {
2
+ results: T[];
3
+ success: boolean;
4
+ }
5
+ interface D1RunResult {
6
+ success: boolean;
7
+ changes: number;
8
+ lastRowId: number;
9
+ }
10
+ interface D1PreparedStatement {
11
+ bind(...values: unknown[]): D1PreparedStatement;
12
+ first<T = Record<string, unknown>>(): Promise<T | null>;
13
+ all<T = Record<string, unknown>>(): Promise<D1Result<T>>;
14
+ run(): Promise<D1RunResult>;
15
+ }
16
+ interface D1Database {
17
+ prepare(sql: string): D1PreparedStatement;
18
+ exec(sql: string): Promise<void>;
19
+ }
20
+
21
+ export type { D1Database as D, D1PreparedStatement as a, D1Result as b, D1RunResult as c };
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@nkmc/gateway",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.cjs",
6
+ "module": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "require": "./dist/index.cjs"
13
+ },
14
+ "./http": {
15
+ "types": "./dist/http.d.ts",
16
+ "import": "./dist/http.js",
17
+ "require": "./dist/http.cjs"
18
+ },
19
+ "./proxy": {
20
+ "types": "./dist/proxy.d.ts",
21
+ "import": "./dist/proxy.js",
22
+ "require": "./dist/proxy.cjs"
23
+ },
24
+ "./testing": {
25
+ "types": "./dist/testing.d.ts",
26
+ "import": "./dist/testing.js",
27
+ "require": "./dist/testing.cjs"
28
+ }
29
+ },
30
+ "scripts": {
31
+ "build": "tsup",
32
+ "dev": "tsup --watch"
33
+ },
34
+ "dependencies": {
35
+ "@nkmc/agent-fs": "workspace:*",
36
+ "@nkmc/core": "^0.1.1",
37
+ "hono": "^4.11.9",
38
+ "jose": "^6.1.3",
39
+ "nanoid": "^5.1.5",
40
+ "stripe": "^20.3.1",
41
+ "yaml": "^2.7.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/better-sqlite3": "^7.6.13",
45
+ "better-sqlite3": "^12.6.2"
46
+ }
47
+ }
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Integration tests: prove that ALL existing D1-backed stores work unchanged
3
+ * on the new createSqliteD1 adapter with real migration SQL applied.
4
+ *
5
+ * Unlike the per-store unit tests (which use SqliteD1 from testing/ and call
6
+ * initSchema()), these tests apply the actual worker migration files to an
7
+ * in-memory SQLite database wrapped by createSqliteD1.
8
+ */
9
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
10
+ import { readFileSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+ import Database from "better-sqlite3";
13
+ import { createSqliteD1 } from "../d1/sqlite-adapter.js";
14
+ import { D1RegistryStore } from "../registry/d1-store.js";
15
+ import { D1CredentialVault } from "../credential/d1-vault.js";
16
+ import { D1MeterStore } from "../metering/d1-store.js";
17
+ import type { D1Database as D1 } from "../d1/types.js";
18
+ import type { ServiceRecord } from "../registry/types.js";
19
+ import type { MeterRecord } from "../metering/types.js";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Helpers
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const MIGRATIONS_DIR = resolve(
26
+ import.meta.dirname ?? __dirname,
27
+ "../../../../migrations",
28
+ );
29
+
30
+ const MIGRATION_FILES = [
31
+ "0001_init.sql",
32
+ "0002_auth_mode.sql",
33
+ "0003_federation.sql",
34
+ ];
35
+
36
+ /** Read and concatenate all migration SQL, split into individual statements. */
37
+ function readMigrations(): string[] {
38
+ return MIGRATION_FILES.map((f) =>
39
+ readFileSync(resolve(MIGRATIONS_DIR, f), "utf-8"),
40
+ );
41
+ }
42
+
43
+ /** Apply all migrations via the D1 exec interface. */
44
+ async function applyMigrations(db: D1): Promise<void> {
45
+ for (const sql of readMigrations()) {
46
+ await db.exec(sql);
47
+ }
48
+ }
49
+
50
+ function makeServiceRecord(
51
+ domain: string,
52
+ overrides?: Partial<ServiceRecord>,
53
+ ): ServiceRecord {
54
+ return {
55
+ domain,
56
+ name: overrides?.name ?? domain,
57
+ description: overrides?.description ?? `Service ${domain}`,
58
+ version: overrides?.version ?? "1.0",
59
+ roles: ["agent"],
60
+ skillMd: "---\nname: test\n---\n# Test",
61
+ endpoints: [
62
+ { method: "GET", path: "/api/test", description: "test endpoint" },
63
+ ],
64
+ isFirstParty: overrides?.isFirstParty ?? false,
65
+ createdAt: overrides?.createdAt ?? Date.now(),
66
+ updatedAt: overrides?.updatedAt ?? Date.now(),
67
+ status: overrides?.status ?? "active",
68
+ isDefault: overrides?.isDefault ?? true,
69
+ };
70
+ }
71
+
72
+ function makeMeterEntry(overrides?: Partial<MeterRecord>): MeterRecord {
73
+ return {
74
+ id: overrides?.id ?? `m_${Math.random().toString(36).slice(2)}`,
75
+ timestamp: overrides?.timestamp ?? Date.now(),
76
+ domain: overrides?.domain ?? "api.example.com",
77
+ version: overrides?.version ?? "1.0",
78
+ endpoint: overrides?.endpoint ?? "GET /api/data",
79
+ agentId: overrides?.agentId ?? "agent-1",
80
+ developerId: overrides?.developerId,
81
+ cost: overrides?.cost ?? 0.05,
82
+ currency: overrides?.currency ?? "USDC",
83
+ };
84
+ }
85
+
86
+ async function generateEncryptionKey(): Promise<CryptoKey> {
87
+ return crypto.subtle.generateKey(
88
+ { name: "AES-GCM", length: 256 },
89
+ false,
90
+ ["encrypt", "decrypt"],
91
+ );
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Tests
96
+ // ---------------------------------------------------------------------------
97
+
98
+ describe("SQLite adapter integration — D1 stores over createSqliteD1", () => {
99
+ let raw: Database.Database;
100
+ let db: D1;
101
+
102
+ beforeEach(async () => {
103
+ raw = new Database(":memory:");
104
+ db = createSqliteD1(raw);
105
+ await applyMigrations(db);
106
+ });
107
+
108
+ afterEach(() => {
109
+ raw.close();
110
+ });
111
+
112
+ // -----------------------------------------------------------------------
113
+ // D1RegistryStore
114
+ // -----------------------------------------------------------------------
115
+ describe("D1RegistryStore", () => {
116
+ let store: D1RegistryStore;
117
+
118
+ beforeEach(() => {
119
+ store = new D1RegistryStore(db);
120
+ });
121
+
122
+ it("put and get a service record", async () => {
123
+ const record = makeServiceRecord("acme-api.com");
124
+ await store.put("acme-api.com", record);
125
+
126
+ const result = await store.get("acme-api.com");
127
+ expect(result).not.toBeNull();
128
+ expect(result!.domain).toBe("acme-api.com");
129
+ expect(result!.name).toBe("acme-api.com");
130
+ expect(result!.endpoints).toEqual(record.endpoints);
131
+ expect(result!.roles).toEqual(["agent"]);
132
+ });
133
+
134
+ it("returns null for unknown domain", async () => {
135
+ expect(await store.get("nonexistent.com")).toBeNull();
136
+ });
137
+
138
+ it("list returns all default services", async () => {
139
+ await store.put("a.com", makeServiceRecord("a.com"));
140
+ await store.put("b.com", makeServiceRecord("b.com", { isFirstParty: true }));
141
+ // Non-default version should not appear
142
+ await store.put(
143
+ "a.com",
144
+ makeServiceRecord("a.com", { version: "2.0", isDefault: false }),
145
+ );
146
+
147
+ const list = await store.list();
148
+ expect(list).toHaveLength(2);
149
+ const domains = list.map((s) => s.domain);
150
+ expect(domains).toContain("a.com");
151
+ expect(domains).toContain("b.com");
152
+ });
153
+
154
+ it("search by description and endpoint", async () => {
155
+ await store.put(
156
+ "weather.io",
157
+ makeServiceRecord("weather.io", { description: "Weather forecasts" }),
158
+ );
159
+ await store.put(
160
+ "store.io",
161
+ makeServiceRecord("store.io", { description: "E-commerce" }),
162
+ );
163
+
164
+ const results = await store.search("weather");
165
+ expect(results).toHaveLength(1);
166
+ expect(results[0].domain).toBe("weather.io");
167
+ });
168
+
169
+ it("stores and retrieves authMode from migration 0002", async () => {
170
+ const record = makeServiceRecord("jwt-api.com");
171
+ (record as ServiceRecord & { authMode: string }).authMode = "nkmc-jwt";
172
+ await store.put("jwt-api.com", record);
173
+
174
+ const result = await store.get("jwt-api.com");
175
+ expect(result!.authMode).toBe("nkmc-jwt");
176
+ });
177
+
178
+ it("stats returns correct counts", async () => {
179
+ await store.put("x.com", makeServiceRecord("x.com"));
180
+ await store.put("y.com", makeServiceRecord("y.com"));
181
+
182
+ const stats = await store.stats();
183
+ expect(stats.serviceCount).toBe(2);
184
+ expect(stats.endpointCount).toBe(2); // 1 endpoint each
185
+ });
186
+ });
187
+
188
+ // -----------------------------------------------------------------------
189
+ // D1CredentialVault
190
+ // -----------------------------------------------------------------------
191
+ describe("D1CredentialVault", () => {
192
+ let vault: D1CredentialVault;
193
+
194
+ beforeEach(async () => {
195
+ const key = await generateEncryptionKey();
196
+ vault = new D1CredentialVault(db, key);
197
+ });
198
+
199
+ it("put and get a pool credential with real AES-GCM encryption", async () => {
200
+ await vault.putPool("api.stripe.com", {
201
+ type: "bearer",
202
+ token: "sk_test_abc123",
203
+ });
204
+
205
+ const cred = await vault.get("api.stripe.com");
206
+ expect(cred).not.toBeNull();
207
+ expect(cred!.scope).toBe("pool");
208
+ expect(cred!.auth).toEqual({
209
+ type: "bearer",
210
+ token: "sk_test_abc123",
211
+ });
212
+ });
213
+
214
+ it("returns null for unknown domain", async () => {
215
+ expect(await vault.get("unknown.com")).toBeNull();
216
+ });
217
+
218
+ it("BYOK takes priority over pool", async () => {
219
+ await vault.putPool("api.stripe.com", {
220
+ type: "bearer",
221
+ token: "pool_token",
222
+ });
223
+ await vault.putByok("api.stripe.com", "dev-42", {
224
+ type: "api-key",
225
+ header: "X-API-Key",
226
+ key: "byok_secret",
227
+ });
228
+
229
+ // With developerId → BYOK wins
230
+ const cred = await vault.get("api.stripe.com", "dev-42");
231
+ expect(cred!.scope).toBe("byok");
232
+ expect(cred!.auth).toEqual({
233
+ type: "api-key",
234
+ header: "X-API-Key",
235
+ key: "byok_secret",
236
+ });
237
+ });
238
+
239
+ it("falls back to pool when no BYOK for the developer", async () => {
240
+ await vault.putPool("api.stripe.com", {
241
+ type: "bearer",
242
+ token: "pool_token",
243
+ });
244
+
245
+ const cred = await vault.get("api.stripe.com", "dev-99");
246
+ expect(cred!.scope).toBe("pool");
247
+ expect(cred!.auth).toEqual({
248
+ type: "bearer",
249
+ token: "pool_token",
250
+ });
251
+ });
252
+
253
+ it("supports api-key auth type", async () => {
254
+ await vault.putPool("openai.com", {
255
+ type: "api-key",
256
+ header: "Authorization",
257
+ key: "sk-proj-xxx",
258
+ });
259
+
260
+ const cred = await vault.get("openai.com");
261
+ expect(cred!.auth).toEqual({
262
+ type: "api-key",
263
+ header: "Authorization",
264
+ key: "sk-proj-xxx",
265
+ });
266
+ });
267
+
268
+ it("listDomains returns all stored domains", async () => {
269
+ await vault.putPool("a.com", { type: "bearer", token: "t1" });
270
+ await vault.putPool("b.com", { type: "bearer", token: "t2" });
271
+
272
+ const domains = await vault.listDomains();
273
+ expect(domains).toContain("a.com");
274
+ expect(domains).toContain("b.com");
275
+ });
276
+ });
277
+
278
+ // -----------------------------------------------------------------------
279
+ // D1MeterStore
280
+ // -----------------------------------------------------------------------
281
+ describe("D1MeterStore", () => {
282
+ let store: D1MeterStore;
283
+
284
+ beforeEach(() => {
285
+ store = new D1MeterStore(db);
286
+ });
287
+
288
+ it("record and query a meter entry", async () => {
289
+ const entry = makeMeterEntry({ id: "meter-1" });
290
+ await store.record(entry);
291
+
292
+ const results = await store.query({ domain: entry.domain });
293
+ expect(results).toHaveLength(1);
294
+ expect(results[0].id).toBe("meter-1");
295
+ expect(results[0].agentId).toBe("agent-1");
296
+ expect(results[0].cost).toBe(0.05);
297
+ });
298
+
299
+ it("sum by agentId", async () => {
300
+ await store.record(
301
+ makeMeterEntry({ id: "m1", agentId: "agent-A", cost: 0.10 }),
302
+ );
303
+ await store.record(
304
+ makeMeterEntry({ id: "m2", agentId: "agent-A", cost: 0.25 }),
305
+ );
306
+ await store.record(
307
+ makeMeterEntry({ id: "m3", agentId: "agent-B", cost: 1.00 }),
308
+ );
309
+
310
+ const { total, currency } = await store.sum({ agentId: "agent-A" });
311
+ expect(total).toBeCloseTo(0.35);
312
+ expect(currency).toBe("USDC");
313
+ });
314
+
315
+ it("returns zero for no matches", async () => {
316
+ const { total } = await store.sum({ domain: "nonexistent.com" });
317
+ expect(total).toBe(0);
318
+ });
319
+
320
+ it("filters by time range", async () => {
321
+ await store.record(makeMeterEntry({ id: "m1", timestamp: 1000 }));
322
+ await store.record(makeMeterEntry({ id: "m2", timestamp: 2000 }));
323
+ await store.record(makeMeterEntry({ id: "m3", timestamp: 3000 }));
324
+
325
+ const results = await store.query({ from: 1500, to: 2500 });
326
+ expect(results).toHaveLength(1);
327
+ expect(results[0].id).toBe("m2");
328
+ });
329
+
330
+ it("records with developerId", async () => {
331
+ await store.record(
332
+ makeMeterEntry({ id: "m1", developerId: "dev-42" }),
333
+ );
334
+
335
+ const results = await store.query({ developerId: "dev-42" });
336
+ expect(results).toHaveLength(1);
337
+ expect(results[0].developerId).toBe("dev-42");
338
+ });
339
+ });
340
+
341
+ // -----------------------------------------------------------------------
342
+ // Cross-cutting: migrations create all expected tables
343
+ // -----------------------------------------------------------------------
344
+ describe("Migration completeness", () => {
345
+ it("all expected tables exist after migrations", async () => {
346
+ const tables = [
347
+ "services",
348
+ "credentials",
349
+ "meter_records",
350
+ "developer_agents",
351
+ "claim_tokens",
352
+ "domain_challenges",
353
+ "peers",
354
+ "lending_rules",
355
+ ];
356
+
357
+ for (const table of tables) {
358
+ const row = await db
359
+ .prepare(
360
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
361
+ )
362
+ .bind(table)
363
+ .first<{ name: string }>();
364
+ expect(row, `table '${table}' should exist`).not.toBeNull();
365
+ }
366
+ });
367
+
368
+ it("auth_mode column exists on services (migration 0002)", async () => {
369
+ // Insert a row with auth_mode set — would fail if column missing
370
+ await db
371
+ .prepare(
372
+ `INSERT INTO services (domain, version, name, skill_md, roles, endpoints, created_at, updated_at, auth_mode)
373
+ VALUES ('test.com', '1.0', 'test', 'md', '[]', '[]', 0, 0, 'nkmc-jwt')`,
374
+ )
375
+ .run();
376
+
377
+ const row = await db
378
+ .prepare("SELECT auth_mode FROM services WHERE domain = ?")
379
+ .bind("test.com")
380
+ .first<{ auth_mode: string }>();
381
+ expect(row!.auth_mode).toBe("nkmc-jwt");
382
+ });
383
+ });
384
+ });
@@ -0,0 +1,134 @@
1
+ import type { D1Database } from "../d1/types.js";
2
+ import type { HttpAuth } from "@nkmc/agent-fs";
3
+ import type { CredentialVault, StoredCredential } from "./types.js";
4
+
5
+ const CREATE_CREDENTIALS = `
6
+ CREATE TABLE IF NOT EXISTS credentials (
7
+ domain TEXT NOT NULL,
8
+ scope TEXT NOT NULL DEFAULT 'pool',
9
+ developer_id TEXT NOT NULL DEFAULT '',
10
+ auth_encrypted TEXT NOT NULL,
11
+ created_at INTEGER NOT NULL,
12
+ updated_at INTEGER NOT NULL,
13
+ PRIMARY KEY (domain, scope, developer_id)
14
+ )`;
15
+
16
+ interface CredentialRow {
17
+ domain: string;
18
+ scope: string;
19
+ developer_id: string;
20
+ auth_encrypted: string;
21
+ created_at: number;
22
+ updated_at: number;
23
+ }
24
+
25
+ async function encrypt(auth: HttpAuth, key: CryptoKey): Promise<string> {
26
+ const iv = crypto.getRandomValues(new Uint8Array(12));
27
+ const plaintext = new TextEncoder().encode(JSON.stringify(auth));
28
+ const ciphertext = await crypto.subtle.encrypt(
29
+ { name: "AES-GCM", iv },
30
+ key,
31
+ plaintext,
32
+ );
33
+ const combined = new Uint8Array(iv.length + ciphertext.byteLength);
34
+ combined.set(iv);
35
+ combined.set(new Uint8Array(ciphertext), iv.length);
36
+ return btoa(String.fromCharCode(...combined));
37
+ }
38
+
39
+ async function decrypt(encoded: string, key: CryptoKey): Promise<HttpAuth> {
40
+ const bytes = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0));
41
+ const iv = bytes.slice(0, 12);
42
+ const ciphertext = bytes.slice(12);
43
+ try {
44
+ const plaintext = await crypto.subtle.decrypt(
45
+ { name: "AES-GCM", iv },
46
+ key,
47
+ ciphertext,
48
+ );
49
+ return JSON.parse(new TextDecoder().decode(plaintext));
50
+ } catch {
51
+ // Fallback: legacy base64-encoded JSON (pre-encryption data)
52
+ return JSON.parse(atob(encoded));
53
+ }
54
+ }
55
+
56
+ export class D1CredentialVault implements CredentialVault {
57
+ constructor(
58
+ private db: D1Database,
59
+ private encryptionKey: CryptoKey,
60
+ ) {}
61
+
62
+ async initSchema(): Promise<void> {
63
+ await this.db.exec(CREATE_CREDENTIALS);
64
+ }
65
+
66
+ async get(domain: string, developerId?: string): Promise<StoredCredential | null> {
67
+ // BYOK first
68
+ if (developerId) {
69
+ const byok = await this.db
70
+ .prepare("SELECT * FROM credentials WHERE domain = ? AND scope = 'byok' AND developer_id = ?")
71
+ .bind(domain, developerId)
72
+ .first<CredentialRow>();
73
+ if (byok) return await this.rowToCredential(byok);
74
+ }
75
+ // Pool fallback
76
+ const pool = await this.db
77
+ .prepare("SELECT * FROM credentials WHERE domain = ? AND scope = 'pool' AND developer_id = ''")
78
+ .bind(domain)
79
+ .first<CredentialRow>();
80
+ return pool ? await this.rowToCredential(pool) : null;
81
+ }
82
+
83
+ async putPool(domain: string, auth: HttpAuth): Promise<void> {
84
+ const now = Date.now();
85
+ await this.db
86
+ .prepare(
87
+ `INSERT OR REPLACE INTO credentials (domain, scope, developer_id, auth_encrypted, created_at, updated_at)
88
+ VALUES (?, 'pool', '', ?, ?, ?)`,
89
+ )
90
+ .bind(domain, await encrypt(auth, this.encryptionKey), now, now)
91
+ .run();
92
+ }
93
+
94
+ async putByok(domain: string, developerId: string, auth: HttpAuth): Promise<void> {
95
+ const now = Date.now();
96
+ await this.db
97
+ .prepare(
98
+ `INSERT OR REPLACE INTO credentials (domain, scope, developer_id, auth_encrypted, created_at, updated_at)
99
+ VALUES (?, 'byok', ?, ?, ?, ?)`,
100
+ )
101
+ .bind(domain, developerId, await encrypt(auth, this.encryptionKey), now, now)
102
+ .run();
103
+ }
104
+
105
+ async delete(domain: string, developerId?: string): Promise<void> {
106
+ if (developerId) {
107
+ await this.db
108
+ .prepare("DELETE FROM credentials WHERE domain = ? AND scope = 'byok' AND developer_id = ?")
109
+ .bind(domain, developerId)
110
+ .run();
111
+ } else {
112
+ await this.db
113
+ .prepare("DELETE FROM credentials WHERE domain = ? AND scope = 'pool' AND developer_id = ''")
114
+ .bind(domain)
115
+ .run();
116
+ }
117
+ }
118
+
119
+ async listDomains(): Promise<string[]> {
120
+ const { results } = await this.db
121
+ .prepare("SELECT DISTINCT domain FROM credentials")
122
+ .all<{ domain: string }>();
123
+ return results.map((r) => r.domain);
124
+ }
125
+
126
+ private async rowToCredential(row: CredentialRow): Promise<StoredCredential> {
127
+ return {
128
+ domain: row.domain,
129
+ auth: await decrypt(row.auth_encrypted, this.encryptionKey),
130
+ scope: row.scope as "pool" | "byok",
131
+ ...(row.developer_id ? { developerId: row.developer_id } : {}),
132
+ };
133
+ }
134
+ }
@@ -0,0 +1,50 @@
1
+ import type { HttpAuth } from "@nkmc/agent-fs";
2
+ import type { CredentialVault, StoredCredential } from "./types.js";
3
+
4
+ export class MemoryCredentialVault implements CredentialVault {
5
+ // key = "pool:domain" or "byok:domain:developerId"
6
+ private credentials = new Map<string, StoredCredential>();
7
+
8
+ private poolKey(domain: string): string {
9
+ return `pool:${domain}`;
10
+ }
11
+
12
+ private byokKey(domain: string, developerId: string): string {
13
+ return `byok:${domain}:${developerId}`;
14
+ }
15
+
16
+ async get(domain: string, developerId?: string): Promise<StoredCredential | null> {
17
+ // BYOK takes priority over pool
18
+ if (developerId) {
19
+ const byok = this.credentials.get(this.byokKey(domain, developerId));
20
+ if (byok) return byok;
21
+ }
22
+ return this.credentials.get(this.poolKey(domain)) ?? null;
23
+ }
24
+
25
+ async putPool(domain: string, auth: HttpAuth): Promise<void> {
26
+ this.credentials.set(this.poolKey(domain), { domain, auth, scope: "pool" });
27
+ }
28
+
29
+ async putByok(domain: string, developerId: string, auth: HttpAuth): Promise<void> {
30
+ this.credentials.set(this.byokKey(domain, developerId), {
31
+ domain, auth, scope: "byok", developerId,
32
+ });
33
+ }
34
+
35
+ async delete(domain: string, developerId?: string): Promise<void> {
36
+ if (developerId) {
37
+ this.credentials.delete(this.byokKey(domain, developerId));
38
+ } else {
39
+ this.credentials.delete(this.poolKey(domain));
40
+ }
41
+ }
42
+
43
+ async listDomains(): Promise<string[]> {
44
+ const domains = new Set<string>();
45
+ for (const cred of this.credentials.values()) {
46
+ domains.add(cred.domain);
47
+ }
48
+ return Array.from(domains);
49
+ }
50
+ }