stellarium-ts 0.11.0 → 0.13.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/client/operatorClient.d.ts +10 -4
- package/dist/client/operatorClient.d.ts.map +1 -1
- package/dist/client/operatorClient.js +51 -7
- package/dist/crypto/utils.d.ts +9 -0
- package/dist/crypto/utils.d.ts.map +1 -1
- package/dist/crypto/utils.js +30 -1
- package/dist/db/db.d.ts +5 -4
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +88 -12
- package/dist/grant/grant.d.ts +132 -0
- package/dist/grant/grant.d.ts.map +1 -0
- package/dist/grant/grant.js +158 -0
- package/dist/grant/repository.d.ts +57 -0
- package/dist/grant/repository.d.ts.map +1 -0
- package/dist/grant/repository.js +105 -0
- package/dist/grant/service.d.ts +141 -0
- package/dist/grant/service.d.ts.map +1 -0
- package/dist/grant/service.js +431 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/peer/repository.d.ts +33 -2
- package/dist/peer/repository.d.ts.map +1 -1
- package/dist/peer/repository.js +43 -21
- package/dist/resource/invoker.d.ts +21 -9
- package/dist/resource/invoker.d.ts.map +1 -1
- package/dist/resource/invoker.js +69 -16
- package/dist/resource/repository.d.ts +47 -8
- package/dist/resource/repository.d.ts.map +1 -1
- package/dist/resource/repository.js +122 -67
- package/dist/routes.d.ts +8 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +303 -49
- package/dist/server.d.ts +62 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +119 -18
- package/dist/service.d.ts +24 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +9 -6
- package/dist/startup.d.ts +14 -0
- package/dist/startup.d.ts.map +1 -1
- package/dist/startup.js +90 -76
- package/dist/tests/client/operatorClient.test.d.ts +2 -0
- package/dist/tests/client/operatorClient.test.d.ts.map +1 -0
- package/dist/tests/client/operatorClient.test.js +96 -0
- package/dist/tests/db/db.test.d.ts +2 -0
- package/dist/tests/db/db.test.d.ts.map +1 -0
- package/dist/tests/db/db.test.js +64 -0
- package/dist/tests/grant/grant.test.d.ts +2 -0
- package/dist/tests/grant/grant.test.d.ts.map +1 -0
- package/dist/tests/grant/grant.test.js +133 -0
- package/dist/tests/grant/service.test.d.ts +2 -0
- package/dist/tests/grant/service.test.d.ts.map +1 -0
- package/dist/tests/grant/service.test.js +415 -0
- package/dist/tests/helpers.d.ts +13 -0
- package/dist/tests/helpers.d.ts.map +1 -0
- package/dist/tests/helpers.js +49 -0
- package/dist/tests/integration/network.test.d.ts +2 -0
- package/dist/tests/integration/network.test.d.ts.map +1 -0
- package/dist/tests/integration/network.test.js +188 -0
- package/dist/tests/peer/repository.test.d.ts +2 -0
- package/dist/tests/peer/repository.test.d.ts.map +1 -0
- package/dist/tests/peer/repository.test.js +50 -0
- package/dist/tests/resource/invoker.test.js +132 -5
- package/dist/tests/resource/repository.test.d.ts +2 -0
- package/dist/tests/resource/repository.test.d.ts.map +1 -0
- package/dist/tests/resource/repository.test.js +151 -0
- package/dist/tests/routes.test.d.ts +2 -0
- package/dist/tests/routes.test.d.ts.map +1 -0
- package/dist/tests/routes.test.js +565 -0
- package/dist/tests/server.test.d.ts +2 -0
- package/dist/tests/server.test.d.ts.map +1 -0
- package/dist/tests/server.test.js +37 -0
- package/dist/tests/startup.test.d.ts +2 -0
- package/dist/tests/startup.test.d.ts.map +1 -0
- package/dist/tests/startup.test.js +75 -0
- package/package.json +2 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import Fastify from "fastify";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
7
|
+
import { verifyData } from "../../crypto/utils.js";
|
|
8
|
+
import { paymentVoucherPayload } from "../../resource/invoker.js";
|
|
9
|
+
import { StellariumNode } from "../../server.js";
|
|
10
|
+
import { COST, generateBase64KeyPair, RECIPIENT, temperatureSource, weatherResource } from "../helpers.js";
|
|
11
|
+
/** A free function only callers with a grant from the provider may run. */
|
|
12
|
+
function vaultResource(baseUrl) {
|
|
13
|
+
return {
|
|
14
|
+
id: 2,
|
|
15
|
+
baseUrl,
|
|
16
|
+
metadata: {},
|
|
17
|
+
path: "com.example",
|
|
18
|
+
name: "Vault",
|
|
19
|
+
functions: [{ name: "open", parameters: [{ name: "city", type: "string" }], returnType: "int32", exec: async () => 7, restricted: true }],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const vaultSource = 'request OpenVault {\n const vault: com.example.Vault\n\n return vault.open(city: "Oslo")\n}';
|
|
23
|
+
const vault = { resourcePath: "com.example", resourceName: "Vault" };
|
|
24
|
+
const HOUR = 3_600_000;
|
|
25
|
+
function freePort() {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const server = createServer();
|
|
28
|
+
server.once("error", reject);
|
|
29
|
+
server.listen(0, "127.0.0.1", () => {
|
|
30
|
+
const { port } = server.address();
|
|
31
|
+
server.close(() => resolve(port));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A provider node hosting the paid weather resource, a caller node that bootstraps from it,
|
|
37
|
+
* and a fake operator that records settlements. Everything runs in-process over real HTTP.
|
|
38
|
+
*/
|
|
39
|
+
describe("two-node network", () => {
|
|
40
|
+
const servers = [];
|
|
41
|
+
const settlements = [];
|
|
42
|
+
const callerKeys = generateBase64KeyPair();
|
|
43
|
+
let dir;
|
|
44
|
+
let operatorUrl;
|
|
45
|
+
let providerUrl;
|
|
46
|
+
let provider;
|
|
47
|
+
async function startNode(node, options) {
|
|
48
|
+
const fastify = Fastify();
|
|
49
|
+
servers.push(fastify);
|
|
50
|
+
await node.start(fastify, { host: "127.0.0.1", baseUrl: `http://127.0.0.1:${options.port}`, ...options });
|
|
51
|
+
return fastify;
|
|
52
|
+
}
|
|
53
|
+
async function startCaller(options = {}) {
|
|
54
|
+
const port = await freePort();
|
|
55
|
+
const node = new StellariumNode();
|
|
56
|
+
const fastify = await startNode(node, {
|
|
57
|
+
port,
|
|
58
|
+
startingPeer: providerUrl,
|
|
59
|
+
...callerKeys,
|
|
60
|
+
apiKey: "caller-key",
|
|
61
|
+
databasePath: path.join(dir, "caller.db"),
|
|
62
|
+
...options,
|
|
63
|
+
});
|
|
64
|
+
return { node, fastify, url: `http://127.0.0.1:${port}` };
|
|
65
|
+
}
|
|
66
|
+
const run = (url, source = temperatureSource) => fetch(`${url}/v1/run`, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { "Content-Type": "application/json", Authorization: "Bearer caller-key" },
|
|
69
|
+
body: JSON.stringify({ source }),
|
|
70
|
+
}).then(response => response.json());
|
|
71
|
+
const getJson = (url) => fetch(url).then(response => response.json());
|
|
72
|
+
beforeEach(async () => {
|
|
73
|
+
dir = await mkdtemp(path.join(tmpdir(), "stellarium-network-"));
|
|
74
|
+
settlements.length = 0;
|
|
75
|
+
const operator = Fastify();
|
|
76
|
+
servers.push(operator);
|
|
77
|
+
operator.post("/v1/payment", async (request) => {
|
|
78
|
+
settlements.push(request.body.voucher);
|
|
79
|
+
return { status: "confirmed" };
|
|
80
|
+
});
|
|
81
|
+
operatorUrl = `${await operator.listen({ port: 0, host: "127.0.0.1" })}/v1`;
|
|
82
|
+
const providerPort = await freePort();
|
|
83
|
+
providerUrl = `http://127.0.0.1:${providerPort}`;
|
|
84
|
+
provider = new StellariumNode();
|
|
85
|
+
await provider.register(weatherResource(providerUrl));
|
|
86
|
+
await provider.register(vaultResource(providerUrl));
|
|
87
|
+
await startNode(provider, {
|
|
88
|
+
port: providerPort,
|
|
89
|
+
...generateBase64KeyPair(),
|
|
90
|
+
operatorUrl,
|
|
91
|
+
databasePath: ":memory:",
|
|
92
|
+
apiKey: "provider-admin",
|
|
93
|
+
approveGrant: ({ details }) => details?.invite === "letmein" ? { lifetimeMs: HOUR } : undefined,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
afterEach(async () => {
|
|
97
|
+
await Promise.all(servers.splice(0).map(server => server.close()));
|
|
98
|
+
await rm(dir, { recursive: true, force: true });
|
|
99
|
+
});
|
|
100
|
+
test("the caller discovers the provider, pays for a call and the provider settles it", async () => {
|
|
101
|
+
const caller = await startCaller();
|
|
102
|
+
// The provider learned about the caller from its signed announcement.
|
|
103
|
+
const providerPeers = await getJson(`${providerUrl}/v1/peers`);
|
|
104
|
+
expect(providerPeers.peers).toEqual([{ baseUrl: caller.url, name: caller.url, publicKey: callerKeys.publicKey }]);
|
|
105
|
+
const callerResources = await getJson(`${caller.url}/v1/resources`);
|
|
106
|
+
expect(callerResources.resources).toContainEqual(expect.objectContaining({
|
|
107
|
+
name: "Weather",
|
|
108
|
+
baseUrl: providerUrl,
|
|
109
|
+
functions: expect.arrayContaining([expect.objectContaining({ name: "temperature", paymentManifest: { costLamports: COST, recipientAddress: RECIPIENT } })]),
|
|
110
|
+
}));
|
|
111
|
+
expect(await run(caller.url)).toEqual({ returnValue: { value: 12, error: null } });
|
|
112
|
+
await vi.waitFor(() => expect(settlements).toHaveLength(1));
|
|
113
|
+
const [voucher] = settlements;
|
|
114
|
+
expect(voucher).toMatchObject({ amount: COST, recipientAddress: RECIPIENT, publicKey: callerKeys.publicKey });
|
|
115
|
+
expect(verifyData(paymentVoucherPayload(voucher), voucher.publicKey, voucher.signature)).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
test("the caller restarts on the same database without failing", async () => {
|
|
118
|
+
const first = await startCaller();
|
|
119
|
+
await first.fastify.close();
|
|
120
|
+
const second = await startCaller();
|
|
121
|
+
expect(await run(second.url)).toEqual({ returnValue: { value: 12, error: null } });
|
|
122
|
+
});
|
|
123
|
+
describe("restricted functions", () => {
|
|
124
|
+
test("the caller needs a grant from the provider, then calls with it", async () => {
|
|
125
|
+
const caller = await startCaller();
|
|
126
|
+
const refused = await run(caller.url, vaultSource);
|
|
127
|
+
expect(refused.returnValue.error.message).toContain(`requires a grant from ${providerUrl}`);
|
|
128
|
+
await caller.node.addGrant(await provider.issueGrant(callerKeys.publicKey, [vault], { lifetimeMs: HOUR }));
|
|
129
|
+
expect(await run(caller.url, vaultSource)).toEqual({ returnValue: { value: 7, error: null } });
|
|
130
|
+
});
|
|
131
|
+
test("the caller asks the provider for a grant, then calls with it", async () => {
|
|
132
|
+
const caller = await startCaller();
|
|
133
|
+
await expect(caller.node.requestGrant(providerUrl, [vault], { invite: "wrong" })).rejects.toThrow("Grant request refused");
|
|
134
|
+
await caller.node.requestGrant(providerUrl, [vault], { invite: "letmein" });
|
|
135
|
+
expect(await run(caller.url, vaultSource)).toEqual({ returnValue: { value: 7, error: null } });
|
|
136
|
+
});
|
|
137
|
+
test("the provider's admin blocks a caller over HTTP, then unblocks it", async () => {
|
|
138
|
+
const caller = await startCaller();
|
|
139
|
+
await caller.node.requestGrant(providerUrl, [vault], { invite: "letmein" });
|
|
140
|
+
const adminRequest = (method) => fetch(`${providerUrl}/v1/admin/blocked-callers/${callerKeys.publicKey}`, {
|
|
141
|
+
method,
|
|
142
|
+
// Fastify refuses a JSON content type with an empty body, so the DELETE sends neither.
|
|
143
|
+
headers: method === "PUT" ? { "Content-Type": "application/json", Authorization: "Bearer provider-admin" } : { Authorization: "Bearer provider-admin" },
|
|
144
|
+
body: method === "PUT" ? JSON.stringify({ reason: "abuse" }) : undefined,
|
|
145
|
+
});
|
|
146
|
+
expect((await adminRequest("PUT")).status).toBe(200);
|
|
147
|
+
const refused = await run(caller.url, vaultSource);
|
|
148
|
+
expect(refused.returnValue.error.message).toContain("Invalid authorization");
|
|
149
|
+
await expect(caller.node.requestGrant(providerUrl, [vault], { invite: "letmein" })).rejects.toThrow("refused");
|
|
150
|
+
expect((await adminRequest("DELETE")).status).toBe(200);
|
|
151
|
+
expect(await run(caller.url, vaultSource)).toEqual({ returnValue: { value: 7, error: null } });
|
|
152
|
+
});
|
|
153
|
+
test("grants passed at start are loaded, and revocation at the provider cuts them off", async () => {
|
|
154
|
+
const chain = await provider.issueGrant(callerKeys.publicKey, [vault], { lifetimeMs: HOUR });
|
|
155
|
+
const caller = await startCaller({ grants: [chain] });
|
|
156
|
+
expect(await run(caller.url, vaultSource)).toEqual({ returnValue: { value: 7, error: null } });
|
|
157
|
+
await provider.revokeGrant(chain[0].id);
|
|
158
|
+
const refused = await run(caller.url, vaultSource);
|
|
159
|
+
expect(refused.returnValue.error.message).toContain("Invalid authorization");
|
|
160
|
+
});
|
|
161
|
+
test("a third node calls through a grant the caller delegated to it", async () => {
|
|
162
|
+
const caller = await startCaller();
|
|
163
|
+
const [root] = await provider.issueGrant(callerKeys.publicKey, [{ resourcePath: "com.example.*" }], { lifetimeMs: HOUR });
|
|
164
|
+
await caller.node.addGrant([root]);
|
|
165
|
+
const userKeys = generateBase64KeyPair();
|
|
166
|
+
const user = await startCaller({ ...userKeys, databasePath: path.join(dir, "user.db") });
|
|
167
|
+
await user.node.addGrant(await caller.node.delegateGrant(root.id, userKeys.publicKey, [vault], { lifetimeMs: HOUR }));
|
|
168
|
+
expect(await run(user.url, vaultSource)).toEqual({ returnValue: { value: 7, error: null } });
|
|
169
|
+
});
|
|
170
|
+
test("the refresher renews a grant past half its lifetime", async () => {
|
|
171
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
172
|
+
const chain = await provider.issueGrant(callerKeys.publicKey, [vault], { lifetimeMs: 400 });
|
|
173
|
+
const caller = await startCaller({ grants: [chain], grantRefreshIntervalMs: 50 });
|
|
174
|
+
await vi.waitFor(() => expect(fetchSpy.mock.calls.some(([url]) => String(url) === `${providerUrl}/v1/grants/renew`)).toBe(true), { timeout: 2000 });
|
|
175
|
+
fetchSpy.mockRestore();
|
|
176
|
+
// Well past the first copy's expiry, the renewed one still works.
|
|
177
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
178
|
+
expect(await run(caller.url, vaultSource)).toEqual({ returnValue: { value: 7, error: null } });
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
test("the caller refuses a call that costs more than its limit", async () => {
|
|
182
|
+
const caller = await startCaller({ costLimitLamports: COST - 1 });
|
|
183
|
+
const result = await run(caller.url);
|
|
184
|
+
expect(result.returnValue.value).toBeNull();
|
|
185
|
+
expect(result.returnValue.error.message).toContain("exceeds the cost limit");
|
|
186
|
+
expect(settlements).toEqual([]);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repository.test.d.ts","sourceRoot":"","sources":["../../../src/tests/peer/repository.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
2
|
+
import { signData } from "../../crypto/utils.js";
|
|
3
|
+
import { openDatabase } from "../../db/db.js";
|
|
4
|
+
import { PeerRepository, peerAnnouncementPayload } from "../../peer/repository.js";
|
|
5
|
+
import { generateBase64KeyPair } from "../helpers.js";
|
|
6
|
+
describe("PeerRepository", () => {
|
|
7
|
+
const keys = generateBase64KeyPair();
|
|
8
|
+
const peer = { baseUrl: "http://peer.example", name: "peer", publicKey: keys.publicKey };
|
|
9
|
+
const expiry = () => new Date(Date.now() + 60_000).toISOString();
|
|
10
|
+
let db;
|
|
11
|
+
let repository;
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
db = await openDatabase(":memory:");
|
|
14
|
+
repository = new PeerRepository(db);
|
|
15
|
+
});
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await db.close();
|
|
18
|
+
});
|
|
19
|
+
function announce(announced, privateKey = keys.privateKey) {
|
|
20
|
+
const at = expiry();
|
|
21
|
+
return repository.announce(announced, signData(peerAnnouncementPayload(announced, at), privateKey), at);
|
|
22
|
+
}
|
|
23
|
+
test("getPeerByPublicKey maps columns to camel case", async () => {
|
|
24
|
+
await repository.create(peer);
|
|
25
|
+
expect(await repository.getPeerByPublicKey(peer.publicKey)).toEqual({ id: expect.any(Number), ...peer });
|
|
26
|
+
expect(await repository.getPeerByPublicKey("unknown")).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
test("create ignores a peer that is already known", async () => {
|
|
29
|
+
await repository.create(peer);
|
|
30
|
+
await repository.create(peer);
|
|
31
|
+
await repository.create({ ...peer, publicKey: "other-key" });
|
|
32
|
+
expect(await repository.getPeerList()).toEqual([peer]);
|
|
33
|
+
});
|
|
34
|
+
test("announce inserts, then updates the same key in place", async () => {
|
|
35
|
+
expect(await announce(peer)).toEqual({ ok: true });
|
|
36
|
+
const { id } = (await repository.getPeerByPublicKey(peer.publicKey));
|
|
37
|
+
const renamed = { ...peer, name: "renamed" };
|
|
38
|
+
expect(await announce(renamed)).toEqual({ ok: true });
|
|
39
|
+
expect(await repository.getPeerByPublicKey(peer.publicKey)).toEqual({ id, ...renamed });
|
|
40
|
+
});
|
|
41
|
+
test("announce rejects a name held by another peer", async () => {
|
|
42
|
+
await announce(peer);
|
|
43
|
+
const other = generateBase64KeyPair();
|
|
44
|
+
expect(await announce({ baseUrl: "http://other.example", name: peer.name, publicKey: other.publicKey }, other.privateKey))
|
|
45
|
+
.toEqual({ ok: false, error: "conflict" });
|
|
46
|
+
});
|
|
47
|
+
test("announce rejects a malformed public key instead of throwing", async () => {
|
|
48
|
+
expect(await announce({ ...peer, publicKey: "not-a-key" })).toEqual({ ok: false, error: "invalid_signature" });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { generateKeyPairSync } from "node:crypto";
|
|
2
|
-
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
3
3
|
import { verifyData } from "../../crypto/utils.js";
|
|
4
|
-
import { CombinationResourceInvoker, paymentVoucherPayload } from "../../resource/invoker.js";
|
|
4
|
+
import { CombinationResourceInvoker, EXTERNAL_INVOKE_MAX_RETRIES, invocationHash, paymentVoucherPayload } from "../../resource/invoker.js";
|
|
5
5
|
function generateBase64KeyPair() {
|
|
6
6
|
const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
|
|
7
7
|
publicKeyEncoding: { type: "spki", format: "der" },
|
|
@@ -13,12 +13,139 @@ function generateBase64KeyPair() {
|
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
15
|
describe("payment voucher", () => {
|
|
16
|
-
test("
|
|
16
|
+
test("signs the voucher fields, binding the call through invocationHash", () => {
|
|
17
17
|
const { publicKey, privateKey } = generateBase64KeyPair();
|
|
18
18
|
const invoker = new CombinationResourceInvoker({}, 100_000, publicKey, privateKey);
|
|
19
19
|
const parameters = [{ name: "city", value: "Oslo" }];
|
|
20
|
-
const voucher = invoker.createPaymentVoucher("4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2", 10000, "com.example", "Weather", parameters);
|
|
21
|
-
expect(
|
|
20
|
+
const voucher = invoker.createPaymentVoucher("4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2", 10000, "com.example", "Weather", "forecast", parameters);
|
|
21
|
+
expect(voucher.invocationHash).toBe(invocationHash("com.example", "Weather", "forecast", parameters));
|
|
22
|
+
expect(verifyData(paymentVoucherPayload(voucher), voucher.publicKey, voucher.signature)).toBe(true);
|
|
22
23
|
expect(verifyData(voucher, voucher.publicKey, voucher.signature)).toBe(false);
|
|
23
24
|
});
|
|
25
|
+
test("invocationHash changes with the function name or parameters", () => {
|
|
26
|
+
const parameters = [{ name: "city", value: "Oslo" }];
|
|
27
|
+
const hash = invocationHash("com.example", "Weather", "forecast", parameters);
|
|
28
|
+
expect(invocationHash("com.example", "Weather", "history", parameters)).not.toBe(hash);
|
|
29
|
+
expect(invocationHash("com.example", "Weather", "forecast", [{ name: "city", value: "Bergen" }])).not.toBe(hash);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe("external invoke retries", () => {
|
|
33
|
+
const resource = { path: "com.example", name: "Weather", metadata: { baseUrl: "https://remote.example" }, functions: [] };
|
|
34
|
+
const parameters = [{ name: "city", value: "Oslo" }];
|
|
35
|
+
function createInvoker(paymentManifest) {
|
|
36
|
+
const { publicKey, privateKey } = generateBase64KeyPair();
|
|
37
|
+
const repository = { getResourceFunction: async () => ({ paymentManifest }) };
|
|
38
|
+
return new CombinationResourceInvoker(repository, 100_000, publicKey, privateKey);
|
|
39
|
+
}
|
|
40
|
+
const ok = () => new Response(JSON.stringify({ value: 42, error: null }), { status: 200 });
|
|
41
|
+
const status = (code) => new Response(null, { status: code });
|
|
42
|
+
let fetchMock;
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
vi.useFakeTimers();
|
|
45
|
+
fetchMock = vi.fn();
|
|
46
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
47
|
+
});
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
vi.useRealTimers();
|
|
50
|
+
vi.unstubAllGlobals();
|
|
51
|
+
});
|
|
52
|
+
test("retries 5xx and network errors with exponential backoff, then succeeds", async () => {
|
|
53
|
+
fetchMock
|
|
54
|
+
.mockResolvedValueOnce(status(503))
|
|
55
|
+
.mockRejectedValueOnce(new TypeError("fetch failed"))
|
|
56
|
+
.mockResolvedValueOnce(status(500))
|
|
57
|
+
.mockResolvedValueOnce(ok());
|
|
58
|
+
const pending = createInvoker().invoke(resource, "forecast", parameters);
|
|
59
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
60
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
61
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
62
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
63
|
+
await vi.advanceTimersByTimeAsync(999);
|
|
64
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
65
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
66
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
67
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
68
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
69
|
+
await expect(pending).resolves.toEqual({ value: 42, error: null });
|
|
70
|
+
});
|
|
71
|
+
test("does not retry 4xx responses other than 429", async () => {
|
|
72
|
+
fetchMock.mockResolvedValue(status(400));
|
|
73
|
+
const result = await createInvoker().invoke(resource, "forecast", parameters);
|
|
74
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(result.error).not.toBeNull();
|
|
76
|
+
});
|
|
77
|
+
test("gives up after the maximum number of retries", async () => {
|
|
78
|
+
fetchMock.mockResolvedValue(status(502));
|
|
79
|
+
const pending = createInvoker().invoke(resource, "forecast", parameters);
|
|
80
|
+
await vi.runAllTimersAsync();
|
|
81
|
+
const result = await pending;
|
|
82
|
+
expect(fetchMock).toHaveBeenCalledTimes(1 + EXTERNAL_INVOKE_MAX_RETRIES);
|
|
83
|
+
expect(result.value).toBeNull();
|
|
84
|
+
expect(result.error).not.toBeNull();
|
|
85
|
+
});
|
|
86
|
+
test("rethrows the last network error once retries are exhausted", async () => {
|
|
87
|
+
fetchMock.mockRejectedValue(new TypeError("fetch failed"));
|
|
88
|
+
const pending = createInvoker().invoke(resource, "forecast", parameters);
|
|
89
|
+
const assertion = expect(pending).rejects.toThrow("fetch failed");
|
|
90
|
+
await vi.runAllTimersAsync();
|
|
91
|
+
await assertion;
|
|
92
|
+
expect(fetchMock).toHaveBeenCalledTimes(1 + EXTERNAL_INVOKE_MAX_RETRIES);
|
|
93
|
+
});
|
|
94
|
+
test("reuses the same payment voucher on every attempt", async () => {
|
|
95
|
+
fetchMock.mockResolvedValueOnce(status(503)).mockResolvedValueOnce(status(503)).mockResolvedValueOnce(ok());
|
|
96
|
+
const invoker = createInvoker({ costLamports: 10000, recipientAddress: "4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2" });
|
|
97
|
+
const pending = invoker.invoke(resource, "forecast", parameters);
|
|
98
|
+
await vi.runAllTimersAsync();
|
|
99
|
+
await pending;
|
|
100
|
+
const bodies = fetchMock.mock.calls.map(([, init]) => init.body);
|
|
101
|
+
expect(bodies).toHaveLength(3);
|
|
102
|
+
expect(new Set(bodies).size).toBe(1);
|
|
103
|
+
expect(JSON.parse(bodies[0]).paymentVoucher.requestId).toBeDefined();
|
|
104
|
+
expect(JSON.parse(bodies[0]).paymentVoucher.invocationHash).toBe(invocationHash("com.example", "Weather", "forecast", parameters));
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
describe("restricted functions", () => {
|
|
108
|
+
const resource = { path: "com.example", name: "Weather", metadata: { baseUrl: "https://remote.example" }, functions: [] };
|
|
109
|
+
const parameters = [{ name: "city", value: "Oslo" }];
|
|
110
|
+
const chain = [{ id: "grant" }];
|
|
111
|
+
function createInvoker(grantService, paymentManifest) {
|
|
112
|
+
const { publicKey, privateKey } = generateBase64KeyPair();
|
|
113
|
+
const repository = { getResourceFunction: async () => ({ restricted: true, paymentManifest }) };
|
|
114
|
+
return new CombinationResourceInvoker(repository, 100_000, publicKey, privateKey, grantService);
|
|
115
|
+
}
|
|
116
|
+
let fetchMock;
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
vi.useFakeTimers();
|
|
119
|
+
fetchMock = vi.fn();
|
|
120
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
121
|
+
});
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
vi.useRealTimers();
|
|
124
|
+
vi.unstubAllGlobals();
|
|
125
|
+
});
|
|
126
|
+
test("fails without a network call when no held grant covers the call", async () => {
|
|
127
|
+
const chainFor = vi.fn(async () => undefined);
|
|
128
|
+
const result = await createInvoker({ chainFor }).invoke(resource, "forecast", parameters);
|
|
129
|
+
expect(chainFor).toHaveBeenCalledWith("https://remote.example", { resourcePath: "com.example", resourceName: "Weather", functionName: "forecast", parameters });
|
|
130
|
+
expect(result).toEqual({ value: null, error: { message: expect.stringContaining("requires a grant from https://remote.example") } });
|
|
131
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
test("fails without a network call when the node has no grant service", async () => {
|
|
134
|
+
const result = await createInvoker(undefined).invoke(resource, "forecast", parameters);
|
|
135
|
+
expect(result.error).not.toBeNull();
|
|
136
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
test("signs a new authorization per attempt and keeps the voucher", async () => {
|
|
139
|
+
fetchMock.mockResolvedValueOnce(new Response(null, { status: 503 })).mockResolvedValueOnce(new Response(JSON.stringify({ value: 42, error: null }), { status: 200 }));
|
|
140
|
+
let nonce = 0;
|
|
141
|
+
const authorize = vi.fn(() => ({ nonce: `nonce-${nonce++}` }));
|
|
142
|
+
const invoker = createInvoker({ chainFor: async () => chain, authorize }, { costLamports: 10000, recipientAddress: "4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2" });
|
|
143
|
+
const pending = invoker.invoke(resource, "forecast", parameters);
|
|
144
|
+
await vi.runAllTimersAsync();
|
|
145
|
+
await expect(pending).resolves.toEqual({ value: 42, error: null });
|
|
146
|
+
const bodies = fetchMock.mock.calls.map(([, init]) => JSON.parse(init.body));
|
|
147
|
+
expect(bodies.map(body => body.authorization.nonce)).toEqual(["nonce-0", "nonce-1"]);
|
|
148
|
+
expect(bodies[0].paymentVoucher).toEqual(bodies[1].paymentVoucher);
|
|
149
|
+
expect(authorize).toHaveBeenCalledWith(chain, { resourcePath: "com.example", resourceName: "Weather", functionName: "forecast", parameters });
|
|
150
|
+
});
|
|
24
151
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repository.test.d.ts","sourceRoot":"","sources":["../../../src/tests/resource/repository.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
2
|
+
import { signData } from "../../crypto/utils.js";
|
|
3
|
+
import { openDatabase } from "../../db/db.js";
|
|
4
|
+
import { PeerRepository } from "../../peer/repository.js";
|
|
5
|
+
import { resourceAnnouncementPayload, ResourceRepository } from "../../resource/repository.js";
|
|
6
|
+
import { generateBase64KeyPair, RECIPIENT, weatherResource } from "../helpers.js";
|
|
7
|
+
const SELF = "http://self.example";
|
|
8
|
+
const PROVIDER = "http://provider.example";
|
|
9
|
+
function resource(name, functions, baseUrl = PROVIDER) {
|
|
10
|
+
return {
|
|
11
|
+
id: 0,
|
|
12
|
+
baseUrl,
|
|
13
|
+
metadata: {},
|
|
14
|
+
path: "com.provider",
|
|
15
|
+
name,
|
|
16
|
+
functions: functions.map(fn => ({ name: "fn", parameters: [], returnType: "string", exec: async () => undefined, ...fn })),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
describe("ResourceRepository", () => {
|
|
20
|
+
let db;
|
|
21
|
+
let repository;
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
db = await openDatabase(":memory:");
|
|
24
|
+
repository = new ResourceRepository(db, [weatherResource(SELF)], SELF);
|
|
25
|
+
});
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await db.close();
|
|
28
|
+
});
|
|
29
|
+
describe("learn", () => {
|
|
30
|
+
test("keeps which functions are restricted", async () => {
|
|
31
|
+
await repository.learn(resource("Vault", [{ name: "open", restricted: true }, { name: "peek" }]));
|
|
32
|
+
const functions = (await repository.getResource("com.provider.Vault"))?.functions;
|
|
33
|
+
expect(functions.map(fn => [fn.name, fn.restricted])).toEqual([["open", true], ["peek", undefined]]);
|
|
34
|
+
expect((await repository.getResourceFunction("com.provider", "Vault", "open"))?.restricted).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
test("is idempotent, so startup can run on every boot", async () => {
|
|
37
|
+
const learned = resource("Forecast", [{ name: "tomorrow" }]);
|
|
38
|
+
await repository.learn(learned);
|
|
39
|
+
await repository.learn(learned);
|
|
40
|
+
expect(await repository.getExternalResources()).toHaveLength(1);
|
|
41
|
+
});
|
|
42
|
+
test("stores several resources served from the same base URL", async () => {
|
|
43
|
+
await repository.learn(resource("Forecast", [{ name: "tomorrow" }]));
|
|
44
|
+
await repository.learn(resource("Radar", [{ name: "image" }]));
|
|
45
|
+
expect((await repository.getExternalResources()).map(r => r.name)).toEqual(["Forecast", "Radar"]);
|
|
46
|
+
});
|
|
47
|
+
test("skips resources hosted by this node", async () => {
|
|
48
|
+
await repository.learn(weatherResource(PROVIDER));
|
|
49
|
+
await repository.learn(resource("Echo", [{ name: "fn" }], SELF));
|
|
50
|
+
expect(await repository.getExternalResources()).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe("getResources", () => {
|
|
54
|
+
beforeEach(async () => {
|
|
55
|
+
for (const name of ["A", "B", "C"]) {
|
|
56
|
+
await repository.learn(resource(name, []));
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
const names = async (offset, limit) => (await repository.getResources(offset, limit)).map(r => r.name);
|
|
60
|
+
test("pages across hosted and learned resources", async () => {
|
|
61
|
+
expect(await names(0, 2)).toEqual(["Weather", "A"]);
|
|
62
|
+
expect(await names(2, 2)).toEqual(["B", "C"]);
|
|
63
|
+
expect(await names(4, 2)).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
test("pages within the hosted resources alone", async () => {
|
|
66
|
+
expect(await names(0, 1)).toEqual(["Weather"]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe("getResource", () => {
|
|
70
|
+
test("returns each function's own payment manifest", async () => {
|
|
71
|
+
await repository.learn(resource("Forecast", [
|
|
72
|
+
{ name: "free" },
|
|
73
|
+
{ name: "paid", paymentManifest: { costLamports: 250, recipientAddress: RECIPIENT } },
|
|
74
|
+
]));
|
|
75
|
+
const found = await repository.getResource("com.provider.Forecast");
|
|
76
|
+
expect(found.metadata).toEqual({ baseUrl: PROVIDER });
|
|
77
|
+
expect(found.functions.find(f => f.name === "free")?.paymentManifest).toBeUndefined();
|
|
78
|
+
expect(found.functions.find(f => f.name === "paid")?.paymentManifest).toMatchObject({ costLamports: 250, recipientAddress: RECIPIENT });
|
|
79
|
+
});
|
|
80
|
+
test("prefers the internal resource", async () => {
|
|
81
|
+
const found = await repository.getResource("com.example.Weather");
|
|
82
|
+
expect(found?.metadata).toEqual({});
|
|
83
|
+
});
|
|
84
|
+
test("returns undefined for an unknown key", async () => {
|
|
85
|
+
expect(await repository.getResource("com.provider.Missing")).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
describe("saveAll", () => {
|
|
89
|
+
const keys = generateBase64KeyPair();
|
|
90
|
+
let peer;
|
|
91
|
+
beforeEach(async () => {
|
|
92
|
+
const peers = new PeerRepository(db);
|
|
93
|
+
await peers.create({ baseUrl: PROVIDER, name: PROVIDER, publicKey: keys.publicKey });
|
|
94
|
+
peer = (await peers.getPeerByPublicKey(keys.publicKey));
|
|
95
|
+
});
|
|
96
|
+
function save(resources, as = peer, privateKey = keys.privateKey) {
|
|
97
|
+
const expiry = new Date(Date.now() + 60_000).toISOString();
|
|
98
|
+
const sent = JSON.parse(JSON.stringify(resources));
|
|
99
|
+
return repository.saveAll(sent, as, signData(resourceAnnouncementPayload(sent, expiry), privateKey), expiry);
|
|
100
|
+
}
|
|
101
|
+
test("adds new functions, parameters and payment manifests to a known resource", async () => {
|
|
102
|
+
await save([resource("Forecast", [{ name: "today" }])]);
|
|
103
|
+
const result = await save([resource("Forecast", [
|
|
104
|
+
{ name: "today", parameters: [{ name: "city", type: "string" }], paymentManifest: { costLamports: 100, recipientAddress: RECIPIENT } },
|
|
105
|
+
{ name: "tomorrow" },
|
|
106
|
+
])]);
|
|
107
|
+
expect(result).toEqual({ ok: true, rejected: [] });
|
|
108
|
+
const today = await repository.getResourceFunction("com.provider", "Forecast", "today");
|
|
109
|
+
expect(today?.parameters).toEqual([{ name: "city", type: "string" }]);
|
|
110
|
+
expect(today?.paymentManifest).toMatchObject({ costLamports: 100 });
|
|
111
|
+
expect(await repository.getResourceFunction("com.provider", "Forecast", "tomorrow")).toBeDefined();
|
|
112
|
+
});
|
|
113
|
+
test("removes functions and payment manifests the peer no longer advertises", async () => {
|
|
114
|
+
await save([resource("Forecast", [{ name: "today", paymentManifest: { costLamports: 100, recipientAddress: RECIPIENT } }, { name: "old" }])]);
|
|
115
|
+
await save([resource("Forecast", [{ name: "today" }])]);
|
|
116
|
+
expect((await repository.getResourceFunction("com.provider", "Forecast", "today"))?.paymentManifest).toBeUndefined();
|
|
117
|
+
expect(await repository.getResourceFunction("com.provider", "Forecast", "old")).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
test("claims a resource learned at startup that points at the peer", async () => {
|
|
120
|
+
await repository.learn(resource("Forecast", [{ name: "today" }]));
|
|
121
|
+
expect(await save([resource("Forecast", [{ name: "tomorrow" }])])).toEqual({ ok: true, rejected: [] });
|
|
122
|
+
expect(await repository.getResourceFunction("com.provider", "Forecast", "tomorrow")).toBeDefined();
|
|
123
|
+
});
|
|
124
|
+
test("will not let one peer take over another peer's resource", async () => {
|
|
125
|
+
await save([resource("Forecast", [{ name: "today" }])]);
|
|
126
|
+
const other = generateBase64KeyPair();
|
|
127
|
+
const peers = new PeerRepository(db);
|
|
128
|
+
await peers.create({ baseUrl: "http://other.example", name: "other", publicKey: other.publicKey });
|
|
129
|
+
const otherPeer = (await peers.getPeerByPublicKey(other.publicKey));
|
|
130
|
+
const result = await save([resource("Forecast", [{ name: "hijacked" }], "http://other.example")], otherPeer, other.privateKey);
|
|
131
|
+
expect(result).toEqual({ ok: true, rejected: ["com.provider.Forecast"] });
|
|
132
|
+
expect((await repository.getResource("com.provider.Forecast"))?.metadata).toEqual({ baseUrl: PROVIDER });
|
|
133
|
+
});
|
|
134
|
+
test("rejects a bad signature, an expired broadcast and a malformed key without throwing", async () => {
|
|
135
|
+
const sent = [resource("Forecast", [])];
|
|
136
|
+
const past = new Date(Date.now() - 1000).toISOString();
|
|
137
|
+
expect(await save(sent, peer, generateBase64KeyPair().privateKey)).toEqual({ ok: false });
|
|
138
|
+
expect(await repository.saveAll(sent, peer, signData(resourceAnnouncementPayload(sent, past), keys.privateKey), past)).toEqual({ ok: false });
|
|
139
|
+
expect(await save(sent, { ...peer, publicKey: "not-a-key" })).toEqual({ ok: false });
|
|
140
|
+
});
|
|
141
|
+
test("rolls back the whole broadcast when a write fails", async () => {
|
|
142
|
+
const result = save([
|
|
143
|
+
resource("Forecast", [{ name: "today" }]),
|
|
144
|
+
// Duplicate function names violate UNIQUE (resource_id, name).
|
|
145
|
+
resource("Radar", [{ name: "image" }, { name: "image" }]),
|
|
146
|
+
]);
|
|
147
|
+
await expect(result).rejects.toThrow();
|
|
148
|
+
expect(await repository.getExternalResources()).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.test.d.ts","sourceRoot":"","sources":["../../src/tests/routes.test.ts"],"names":[],"mappings":""}
|