@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,16 @@
|
|
|
1
|
+
import type { HttpAuth } from "@nkmc/agent-fs";
|
|
2
|
+
|
|
3
|
+
export interface StoredCredential {
|
|
4
|
+
domain: string;
|
|
5
|
+
auth: HttpAuth;
|
|
6
|
+
scope: "pool" | "byok";
|
|
7
|
+
developerId?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CredentialVault {
|
|
11
|
+
get(domain: string, developerId?: string): Promise<StoredCredential | null>;
|
|
12
|
+
putPool(domain: string, auth: HttpAuth): Promise<void>;
|
|
13
|
+
putByok(domain: string, developerId: string, auth: HttpAuth): Promise<void>;
|
|
14
|
+
delete(domain: string, developerId?: string): Promise<void>;
|
|
15
|
+
listDomains(): Promise<string[]>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { createSqliteD1 } from "../sqlite-adapter.js";
|
|
4
|
+
import type { D1Database } from "../types.js";
|
|
5
|
+
|
|
6
|
+
describe("createSqliteD1", () => {
|
|
7
|
+
let raw: Database.Database;
|
|
8
|
+
let db: D1Database;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
raw = new Database(":memory:");
|
|
12
|
+
db = createSqliteD1(raw);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
raw.close();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("exec creates table", async () => {
|
|
20
|
+
await db.exec(
|
|
21
|
+
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL)",
|
|
22
|
+
);
|
|
23
|
+
// verify table exists by inserting a row via the raw db
|
|
24
|
+
const info = raw.prepare("INSERT INTO t (name) VALUES (?)").run("hello");
|
|
25
|
+
expect(info.changes).toBe(1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("prepare/bind/run inserts row", async () => {
|
|
29
|
+
await db.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, val TEXT)");
|
|
30
|
+
const result = await db.prepare("INSERT INTO items (val) VALUES (?)").bind("foo").run();
|
|
31
|
+
expect(result.success).toBe(true);
|
|
32
|
+
expect(result.changes).toBe(1);
|
|
33
|
+
expect(result.lastRowId).toBeGreaterThan(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("prepare/bind/first reads single row", async () => {
|
|
37
|
+
await db.exec("CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT)");
|
|
38
|
+
await db.prepare("INSERT INTO kv (k, v) VALUES (?, ?)").bind("a", "1").run();
|
|
39
|
+
await db.prepare("INSERT INTO kv (k, v) VALUES (?, ?)").bind("b", "2").run();
|
|
40
|
+
|
|
41
|
+
const row = await db.prepare("SELECT * FROM kv WHERE k = ?").bind("a").first<{ k: string; v: string }>();
|
|
42
|
+
expect(row).toEqual({ k: "a", v: "1" });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("prepare/bind/all reads multiple rows", async () => {
|
|
46
|
+
await db.exec("CREATE TABLE nums (n INTEGER)");
|
|
47
|
+
await db.prepare("INSERT INTO nums (n) VALUES (?)").bind(10).run();
|
|
48
|
+
await db.prepare("INSERT INTO nums (n) VALUES (?)").bind(20).run();
|
|
49
|
+
await db.prepare("INSERT INTO nums (n) VALUES (?)").bind(30).run();
|
|
50
|
+
|
|
51
|
+
const result = await db.prepare("SELECT * FROM nums ORDER BY n").bind().all<{ n: number }>();
|
|
52
|
+
expect(result.success).toBe(true);
|
|
53
|
+
expect(result.results).toEqual([{ n: 10 }, { n: 20 }, { n: 30 }]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("first returns null for no match", async () => {
|
|
57
|
+
await db.exec("CREATE TABLE empty (id INTEGER PRIMARY KEY)");
|
|
58
|
+
const row = await db.prepare("SELECT * FROM empty WHERE id = ?").bind(999).first();
|
|
59
|
+
expect(row).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("exec handles multiple statements", async () => {
|
|
63
|
+
await db.exec(`
|
|
64
|
+
CREATE TABLE a (id INTEGER PRIMARY KEY);
|
|
65
|
+
CREATE TABLE b (id INTEGER PRIMARY KEY);
|
|
66
|
+
`);
|
|
67
|
+
// verify both tables exist
|
|
68
|
+
await db.prepare("INSERT INTO a (id) VALUES (?)").bind(1).run();
|
|
69
|
+
await db.prepare("INSERT INTO b (id) VALUES (?)").bind(1).run();
|
|
70
|
+
const rowA = await db.prepare("SELECT * FROM a WHERE id = ?").bind(1).first();
|
|
71
|
+
const rowB = await db.prepare("SELECT * FROM b WHERE id = ?").bind(1).first();
|
|
72
|
+
expect(rowA).toEqual({ id: 1 });
|
|
73
|
+
expect(rowB).toEqual({ id: 1 });
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import type {
|
|
3
|
+
D1Database,
|
|
4
|
+
D1PreparedStatement,
|
|
5
|
+
D1Result,
|
|
6
|
+
D1RunResult,
|
|
7
|
+
} from "./types.js";
|
|
8
|
+
|
|
9
|
+
class BoundStatement implements D1PreparedStatement {
|
|
10
|
+
private params: unknown[] = [];
|
|
11
|
+
|
|
12
|
+
constructor(
|
|
13
|
+
private db: Database.Database,
|
|
14
|
+
private sql: string,
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
bind(...values: unknown[]): D1PreparedStatement {
|
|
18
|
+
this.params = values;
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async first<T = Record<string, unknown>>(): Promise<T | null> {
|
|
23
|
+
const stmt = this.db.prepare(this.sql);
|
|
24
|
+
const row = stmt.get(...this.params) as T | undefined;
|
|
25
|
+
return row ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async all<T = Record<string, unknown>>(): Promise<D1Result<T>> {
|
|
29
|
+
const stmt = this.db.prepare(this.sql);
|
|
30
|
+
const rows = stmt.all(...this.params) as T[];
|
|
31
|
+
return { results: rows, success: true };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async run(): Promise<D1RunResult> {
|
|
35
|
+
const stmt = this.db.prepare(this.sql);
|
|
36
|
+
const info = stmt.run(...this.params);
|
|
37
|
+
return {
|
|
38
|
+
success: true,
|
|
39
|
+
changes: info.changes,
|
|
40
|
+
lastRowId: Number(info.lastInsertRowid),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Wraps a better-sqlite3 Database instance to implement the D1Database interface.
|
|
47
|
+
* This allows all existing D1-based stores (RegistryStore, CredentialVault, MeterStore)
|
|
48
|
+
* to work with a local SQLite database for standalone deployments.
|
|
49
|
+
*/
|
|
50
|
+
export function createSqliteD1(db: Database.Database): D1Database {
|
|
51
|
+
return {
|
|
52
|
+
prepare(sql: string): D1PreparedStatement {
|
|
53
|
+
return new BoundStatement(db, sql);
|
|
54
|
+
},
|
|
55
|
+
async exec(sql: string): Promise<void> {
|
|
56
|
+
db.exec(sql);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
package/src/d1/types.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface D1Result<T = Record<string, unknown>> {
|
|
2
|
+
results: T[];
|
|
3
|
+
success: boolean;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface D1RunResult {
|
|
7
|
+
success: boolean;
|
|
8
|
+
changes: number;
|
|
9
|
+
lastRowId: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface D1PreparedStatement {
|
|
13
|
+
bind(...values: unknown[]): D1PreparedStatement;
|
|
14
|
+
first<T = Record<string, unknown>>(): Promise<T | null>;
|
|
15
|
+
all<T = Record<string, unknown>>(): Promise<D1Result<T>>;
|
|
16
|
+
run(): Promise<D1RunResult>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface D1Database {
|
|
20
|
+
prepare(sql: string): D1PreparedStatement;
|
|
21
|
+
exec(sql: string): Promise<void>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import Database from "better-sqlite3";
|
|
5
|
+
import { createSqliteD1 } from "../../d1/sqlite-adapter.js";
|
|
6
|
+
import { D1PeerStore } from "../d1-peer-store.js";
|
|
7
|
+
import type { D1Database as D1 } from "../../d1/types.js";
|
|
8
|
+
import type { PeerGateway, LendingRule } from "../types.js";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Helpers
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const MIGRATIONS_DIR = resolve(
|
|
15
|
+
import.meta.dirname ?? __dirname,
|
|
16
|
+
"../../../../../migrations",
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const MIGRATION_FILES = [
|
|
20
|
+
"0001_init.sql",
|
|
21
|
+
"0002_auth_mode.sql",
|
|
22
|
+
"0003_federation.sql",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
async function applyMigrations(db: D1): Promise<void> {
|
|
26
|
+
for (const f of MIGRATION_FILES) {
|
|
27
|
+
const sql = readFileSync(resolve(MIGRATIONS_DIR, f), "utf-8");
|
|
28
|
+
await db.exec(sql);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makePeer(overrides: Partial<PeerGateway> = {}): PeerGateway {
|
|
33
|
+
return {
|
|
34
|
+
id: "peer-1",
|
|
35
|
+
name: "Test Gateway",
|
|
36
|
+
url: "https://peer1.example.com",
|
|
37
|
+
sharedSecret: "secret-abc",
|
|
38
|
+
status: "active",
|
|
39
|
+
advertisedDomains: ["api.example.com"],
|
|
40
|
+
lastSeen: Date.now(),
|
|
41
|
+
createdAt: Date.now(),
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeRule(overrides: Partial<LendingRule> = {}): LendingRule {
|
|
47
|
+
return {
|
|
48
|
+
domain: "api.example.com",
|
|
49
|
+
allow: true,
|
|
50
|
+
peers: "*",
|
|
51
|
+
pricing: { mode: "free" },
|
|
52
|
+
createdAt: Date.now(),
|
|
53
|
+
updatedAt: Date.now(),
|
|
54
|
+
...overrides,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Tests
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
describe("D1PeerStore — SQLite integration", () => {
|
|
63
|
+
let raw: Database.Database;
|
|
64
|
+
let db: D1;
|
|
65
|
+
let store: D1PeerStore;
|
|
66
|
+
|
|
67
|
+
beforeEach(async () => {
|
|
68
|
+
raw = new Database(":memory:");
|
|
69
|
+
db = createSqliteD1(raw);
|
|
70
|
+
await applyMigrations(db);
|
|
71
|
+
store = new D1PeerStore(db);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
raw.close();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// -----------------------------------------------------------------------
|
|
79
|
+
// Peer CRUD
|
|
80
|
+
// -----------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
it("putPeer then getPeer returns the peer", async () => {
|
|
83
|
+
const peer = makePeer();
|
|
84
|
+
await store.putPeer(peer);
|
|
85
|
+
|
|
86
|
+
const result = await store.getPeer("peer-1");
|
|
87
|
+
expect(result).not.toBeNull();
|
|
88
|
+
expect(result!.id).toBe("peer-1");
|
|
89
|
+
expect(result!.name).toBe("Test Gateway");
|
|
90
|
+
expect(result!.url).toBe("https://peer1.example.com");
|
|
91
|
+
expect(result!.sharedSecret).toBe("secret-abc");
|
|
92
|
+
expect(result!.status).toBe("active");
|
|
93
|
+
expect(result!.advertisedDomains).toEqual(["api.example.com"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("getPeer returns null for unknown id", async () => {
|
|
97
|
+
expect(await store.getPeer("nonexistent")).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("deletePeer removes the peer", async () => {
|
|
101
|
+
await store.putPeer(makePeer());
|
|
102
|
+
await store.deletePeer("peer-1");
|
|
103
|
+
expect(await store.getPeer("peer-1")).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("listPeers only returns active peers", async () => {
|
|
107
|
+
await store.putPeer(makePeer({ id: "a", status: "active", name: "A" }));
|
|
108
|
+
await store.putPeer(makePeer({ id: "b", status: "inactive", name: "B" }));
|
|
109
|
+
await store.putPeer(makePeer({ id: "c", status: "active", name: "C" }));
|
|
110
|
+
|
|
111
|
+
const list = await store.listPeers();
|
|
112
|
+
expect(list).toHaveLength(2);
|
|
113
|
+
expect(list.map((p) => p.id).sort()).toEqual(["a", "c"]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("updateLastSeen updates the timestamp", async () => {
|
|
117
|
+
await store.putPeer(makePeer({ lastSeen: 1000 }));
|
|
118
|
+
await store.updateLastSeen("peer-1", 9999);
|
|
119
|
+
|
|
120
|
+
const peer = await store.getPeer("peer-1");
|
|
121
|
+
expect(peer!.lastSeen).toBe(9999);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("updateLastSeen is a no-op for unknown peer", async () => {
|
|
125
|
+
// should not throw
|
|
126
|
+
await store.updateLastSeen("nonexistent", Date.now());
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("putPeer overwrites existing peer", async () => {
|
|
130
|
+
await store.putPeer(makePeer({ name: "Original" }));
|
|
131
|
+
await store.putPeer(makePeer({ name: "Updated" }));
|
|
132
|
+
|
|
133
|
+
const peer = await store.getPeer("peer-1");
|
|
134
|
+
expect(peer!.name).toBe("Updated");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// -----------------------------------------------------------------------
|
|
138
|
+
// Lending Rule CRUD
|
|
139
|
+
// -----------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
it("putRule then getRule returns the rule", async () => {
|
|
142
|
+
const rule = makeRule();
|
|
143
|
+
await store.putRule(rule);
|
|
144
|
+
|
|
145
|
+
const result = await store.getRule("api.example.com");
|
|
146
|
+
expect(result).not.toBeNull();
|
|
147
|
+
expect(result!.domain).toBe("api.example.com");
|
|
148
|
+
expect(result!.allow).toBe(true);
|
|
149
|
+
expect(result!.peers).toBe("*");
|
|
150
|
+
expect(result!.pricing).toEqual({ mode: "free" });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("getRule returns null for unknown domain", async () => {
|
|
154
|
+
expect(await store.getRule("unknown.example.com")).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("deleteRule removes the rule", async () => {
|
|
158
|
+
await store.putRule(makeRule());
|
|
159
|
+
await store.deleteRule("api.example.com");
|
|
160
|
+
expect(await store.getRule("api.example.com")).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("listRules returns all rules", async () => {
|
|
164
|
+
await store.putRule(makeRule({ domain: "a.com" }));
|
|
165
|
+
await store.putRule(makeRule({ domain: "b.com" }));
|
|
166
|
+
await store.putRule(makeRule({ domain: "c.com" }));
|
|
167
|
+
|
|
168
|
+
const list = await store.listRules();
|
|
169
|
+
expect(list).toHaveLength(3);
|
|
170
|
+
expect(list.map((r) => r.domain).sort()).toEqual(["a.com", "b.com", "c.com"]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("putRule overwrites existing rule", async () => {
|
|
174
|
+
await store.putRule(makeRule({ domain: "x.com", allow: true }));
|
|
175
|
+
await store.putRule(makeRule({ domain: "x.com", allow: false }));
|
|
176
|
+
|
|
177
|
+
const rule = await store.getRule("x.com");
|
|
178
|
+
expect(rule!.allow).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("stores and retrieves rateLimit", async () => {
|
|
182
|
+
const rule = makeRule({
|
|
183
|
+
rateLimit: { requests: 100, window: "minute" },
|
|
184
|
+
});
|
|
185
|
+
await store.putRule(rule);
|
|
186
|
+
|
|
187
|
+
const result = await store.getRule("api.example.com");
|
|
188
|
+
expect(result!.rateLimit).toEqual({ requests: 100, window: "minute" });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("stores rule with specific peer list", async () => {
|
|
192
|
+
const rule = makeRule({
|
|
193
|
+
peers: ["peer-1", "peer-2"],
|
|
194
|
+
pricing: { mode: "per-request", amount: 0.01 },
|
|
195
|
+
});
|
|
196
|
+
await store.putRule(rule);
|
|
197
|
+
|
|
198
|
+
const result = await store.getRule("api.example.com");
|
|
199
|
+
expect(result!.peers).toEqual(["peer-1", "peer-2"]);
|
|
200
|
+
expect(result!.pricing).toEqual({ mode: "per-request", amount: 0.01 });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// -----------------------------------------------------------------------
|
|
204
|
+
// Migration completeness
|
|
205
|
+
// -----------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
it("peers and lending_rules tables exist after migrations", async () => {
|
|
208
|
+
for (const table of ["peers", "lending_rules"]) {
|
|
209
|
+
const row = await db
|
|
210
|
+
.prepare(
|
|
211
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
|
212
|
+
)
|
|
213
|
+
.bind(table)
|
|
214
|
+
.first<{ name: string }>();
|
|
215
|
+
expect(row, `table '${table}' should exist`).not.toBeNull();
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { PeerClient } from "../peer-client.js";
|
|
3
|
+
import type { PeerGateway } from "../types.js";
|
|
4
|
+
|
|
5
|
+
function makePeer(overrides: Partial<PeerGateway> = {}): PeerGateway {
|
|
6
|
+
return {
|
|
7
|
+
id: "peer-1",
|
|
8
|
+
name: "Test Gateway",
|
|
9
|
+
url: "https://peer1.example.com",
|
|
10
|
+
sharedSecret: "secret-abc",
|
|
11
|
+
status: "active",
|
|
12
|
+
advertisedDomains: ["api.example.com"],
|
|
13
|
+
lastSeen: Date.now(),
|
|
14
|
+
createdAt: Date.now(),
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("PeerClient", () => {
|
|
20
|
+
let client: PeerClient;
|
|
21
|
+
const originalFetch = globalThis.fetch;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
client = new PeerClient("self-gateway");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
globalThis.fetch = originalFetch;
|
|
29
|
+
vi.restoreAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("query", () => {
|
|
33
|
+
it("returns available:true when peer responds with it", async () => {
|
|
34
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
35
|
+
ok: true,
|
|
36
|
+
json: async () => ({
|
|
37
|
+
available: true,
|
|
38
|
+
pricing: { mode: "free" },
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const result = await client.query(makePeer(), "api.example.com");
|
|
43
|
+
|
|
44
|
+
expect(result).toEqual({
|
|
45
|
+
available: true,
|
|
46
|
+
pricing: { mode: "free" },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
50
|
+
"https://peer1.example.com/federation/query",
|
|
51
|
+
expect.objectContaining({
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: expect.objectContaining({
|
|
54
|
+
"X-Peer-Id": "self-gateway",
|
|
55
|
+
Authorization: "Bearer secret-abc",
|
|
56
|
+
}),
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns available:false on HTTP error", async () => {
|
|
62
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
63
|
+
ok: false,
|
|
64
|
+
status: 500,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const result = await client.query(makePeer(), "api.example.com");
|
|
68
|
+
expect(result).toEqual({ available: false });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns available:false on network error", async () => {
|
|
72
|
+
globalThis.fetch = vi
|
|
73
|
+
.fn()
|
|
74
|
+
.mockRejectedValue(new Error("Network error"));
|
|
75
|
+
|
|
76
|
+
const result = await client.query(makePeer(), "api.example.com");
|
|
77
|
+
expect(result).toEqual({ available: false });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("exec", () => {
|
|
82
|
+
it("returns data on success", async () => {
|
|
83
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
84
|
+
ok: true,
|
|
85
|
+
status: 200,
|
|
86
|
+
json: async () => ({ ok: true, data: ["file1.txt", "file2.txt"] }),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const result = await client.exec(makePeer(), {
|
|
90
|
+
command: "ls /api.example.com/",
|
|
91
|
+
agentId: "agent-1",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(result).toEqual({
|
|
95
|
+
ok: true,
|
|
96
|
+
data: ["file1.txt", "file2.txt"],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
100
|
+
"https://peer1.example.com/federation/exec",
|
|
101
|
+
expect.objectContaining({
|
|
102
|
+
method: "POST",
|
|
103
|
+
body: JSON.stringify({
|
|
104
|
+
command: "ls /api.example.com/",
|
|
105
|
+
agentId: "agent-1",
|
|
106
|
+
}),
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns paymentRequired on 402", async () => {
|
|
112
|
+
const headers = new Map([
|
|
113
|
+
["X-402-Price", "100"],
|
|
114
|
+
["X-402-Currency", "USD"],
|
|
115
|
+
["X-402-Pay-To", "wallet-abc"],
|
|
116
|
+
["X-402-Network", "lightning"],
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
120
|
+
ok: false,
|
|
121
|
+
status: 402,
|
|
122
|
+
headers: {
|
|
123
|
+
get: (key: string) => headers.get(key) ?? null,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const result = await client.exec(makePeer(), {
|
|
128
|
+
command: "cat /api.example.com/data",
|
|
129
|
+
agentId: "agent-1",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(result).toEqual({
|
|
133
|
+
ok: false,
|
|
134
|
+
paymentRequired: {
|
|
135
|
+
price: 100,
|
|
136
|
+
currency: "USD",
|
|
137
|
+
payTo: "wallet-abc",
|
|
138
|
+
network: "lightning",
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("returns error on non-402 failure", async () => {
|
|
144
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
145
|
+
ok: false,
|
|
146
|
+
status: 500,
|
|
147
|
+
json: async () => ({ error: "Internal server error" }),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const result = await client.exec(makePeer(), {
|
|
151
|
+
command: "ls /api.example.com/",
|
|
152
|
+
agentId: "agent-1",
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(result).toEqual({
|
|
156
|
+
ok: false,
|
|
157
|
+
error: "Internal server error",
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("returns error on network failure", async () => {
|
|
162
|
+
globalThis.fetch = vi
|
|
163
|
+
.fn()
|
|
164
|
+
.mockRejectedValue(new Error("Connection refused"));
|
|
165
|
+
|
|
166
|
+
const result = await client.exec(makePeer(), {
|
|
167
|
+
command: "ls /api.example.com/",
|
|
168
|
+
agentId: "agent-1",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(result).toEqual({
|
|
172
|
+
ok: false,
|
|
173
|
+
error: "Connection refused",
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("announce", () => {
|
|
179
|
+
it("sends domains to peer", async () => {
|
|
180
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
181
|
+
ok: true,
|
|
182
|
+
json: async () => ({ ok: true }),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await client.announce(makePeer(), [
|
|
186
|
+
"api.example.com",
|
|
187
|
+
"github.com",
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
191
|
+
"https://peer1.example.com/federation/announce",
|
|
192
|
+
expect.objectContaining({
|
|
193
|
+
method: "POST",
|
|
194
|
+
headers: expect.objectContaining({
|
|
195
|
+
"X-Peer-Id": "self-gateway",
|
|
196
|
+
Authorization: "Bearer secret-abc",
|
|
197
|
+
}),
|
|
198
|
+
body: JSON.stringify({
|
|
199
|
+
domains: ["api.example.com", "github.com"],
|
|
200
|
+
}),
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|