stellarium-ts 0.12.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.
Files changed (77) hide show
  1. package/dist/client/operatorClient.d.ts +10 -4
  2. package/dist/client/operatorClient.d.ts.map +1 -1
  3. package/dist/client/operatorClient.js +51 -7
  4. package/dist/crypto/utils.d.ts +9 -0
  5. package/dist/crypto/utils.d.ts.map +1 -1
  6. package/dist/crypto/utils.js +30 -1
  7. package/dist/db/db.d.ts +5 -4
  8. package/dist/db/db.d.ts.map +1 -1
  9. package/dist/db/db.js +88 -12
  10. package/dist/grant/grant.d.ts +132 -0
  11. package/dist/grant/grant.d.ts.map +1 -0
  12. package/dist/grant/grant.js +158 -0
  13. package/dist/grant/repository.d.ts +57 -0
  14. package/dist/grant/repository.d.ts.map +1 -0
  15. package/dist/grant/repository.js +105 -0
  16. package/dist/grant/service.d.ts +141 -0
  17. package/dist/grant/service.d.ts.map +1 -0
  18. package/dist/grant/service.js +431 -0
  19. package/dist/index.d.ts +6 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +5 -1
  22. package/dist/peer/repository.d.ts +33 -2
  23. package/dist/peer/repository.d.ts.map +1 -1
  24. package/dist/peer/repository.js +43 -21
  25. package/dist/resource/invoker.d.ts +21 -9
  26. package/dist/resource/invoker.d.ts.map +1 -1
  27. package/dist/resource/invoker.js +69 -16
  28. package/dist/resource/repository.d.ts +47 -8
  29. package/dist/resource/repository.d.ts.map +1 -1
  30. package/dist/resource/repository.js +122 -67
  31. package/dist/routes.d.ts +8 -0
  32. package/dist/routes.d.ts.map +1 -1
  33. package/dist/routes.js +302 -48
  34. package/dist/server.d.ts +62 -0
  35. package/dist/server.d.ts.map +1 -1
  36. package/dist/server.js +119 -18
  37. package/dist/service.d.ts +24 -1
  38. package/dist/service.d.ts.map +1 -1
  39. package/dist/service.js +9 -6
  40. package/dist/startup.d.ts +14 -0
  41. package/dist/startup.d.ts.map +1 -1
  42. package/dist/startup.js +90 -76
  43. package/dist/tests/client/operatorClient.test.d.ts +2 -0
  44. package/dist/tests/client/operatorClient.test.d.ts.map +1 -0
  45. package/dist/tests/client/operatorClient.test.js +96 -0
  46. package/dist/tests/db/db.test.d.ts +2 -0
  47. package/dist/tests/db/db.test.d.ts.map +1 -0
  48. package/dist/tests/db/db.test.js +64 -0
  49. package/dist/tests/grant/grant.test.d.ts +2 -0
  50. package/dist/tests/grant/grant.test.d.ts.map +1 -0
  51. package/dist/tests/grant/grant.test.js +133 -0
  52. package/dist/tests/grant/service.test.d.ts +2 -0
  53. package/dist/tests/grant/service.test.d.ts.map +1 -0
  54. package/dist/tests/grant/service.test.js +415 -0
  55. package/dist/tests/helpers.d.ts +13 -0
  56. package/dist/tests/helpers.d.ts.map +1 -0
  57. package/dist/tests/helpers.js +49 -0
  58. package/dist/tests/integration/network.test.d.ts +2 -0
  59. package/dist/tests/integration/network.test.d.ts.map +1 -0
  60. package/dist/tests/integration/network.test.js +188 -0
  61. package/dist/tests/peer/repository.test.d.ts +2 -0
  62. package/dist/tests/peer/repository.test.d.ts.map +1 -0
  63. package/dist/tests/peer/repository.test.js +50 -0
  64. package/dist/tests/resource/invoker.test.js +132 -5
  65. package/dist/tests/resource/repository.test.d.ts +2 -0
  66. package/dist/tests/resource/repository.test.d.ts.map +1 -0
  67. package/dist/tests/resource/repository.test.js +151 -0
  68. package/dist/tests/routes.test.d.ts +2 -0
  69. package/dist/tests/routes.test.d.ts.map +1 -0
  70. package/dist/tests/routes.test.js +565 -0
  71. package/dist/tests/server.test.d.ts +2 -0
  72. package/dist/tests/server.test.d.ts.map +1 -0
  73. package/dist/tests/server.test.js +37 -0
  74. package/dist/tests/startup.test.d.ts +2 -0
  75. package/dist/tests/startup.test.d.ts.map +1 -0
  76. package/dist/tests/startup.test.js +75 -0
  77. package/package.json +2 -1
@@ -0,0 +1,565 @@
1
+ import Fastify from "fastify";
2
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
3
+ import { signData } from "../crypto/utils.js";
4
+ import { openDatabase } from "../db/db.js";
5
+ import { grantRenewalPayload, grantRequestPayload } from "../grant/grant.js";
6
+ import { GrantRepository } from "../grant/repository.js";
7
+ import { GrantService } from "../grant/service.js";
8
+ import { peerAnnouncementPayload } from "../peer/repository.js";
9
+ import { CombinationResourceInvoker, paymentVoucherPayload } from "../resource/invoker.js";
10
+ import { resourceAnnouncementPayload } from "../resource/repository.js";
11
+ import routes from "../routes.js";
12
+ import { createService } from "../service.js";
13
+ import { COST, generateBase64KeyPair, RECIPIENT, temperatureSource, testConfig, weatherResource } from "./helpers.js";
14
+ const parameters = [{ name: "city", value: "Oslo" }];
15
+ const inMinutes = (minutes) => new Date(Date.now() + minutes * 60_000).toISOString();
16
+ describe("routes", () => {
17
+ const weather = weatherResource();
18
+ const caller = generateBase64KeyPair();
19
+ // Signs vouchers as a remote caller would. Its local branch never touches the repository.
20
+ const callerInvoker = new CombinationResourceInvoker(undefined, 100_000, caller.publicKey, caller.privateKey);
21
+ let db;
22
+ let config;
23
+ let service;
24
+ let fastify;
25
+ let settlePayment;
26
+ beforeEach(async () => {
27
+ db = await openDatabase(":memory:");
28
+ config = testConfig();
29
+ service = createService(db, [weather], config);
30
+ settlePayment = vi.spyOn(service.operatorClient, "settlePayment").mockResolvedValue(undefined);
31
+ fastify = Fastify();
32
+ await fastify.register(routes, { prefix: "/v1", service });
33
+ await fastify.ready();
34
+ });
35
+ afterEach(async () => {
36
+ await fastify.close();
37
+ await db.close();
38
+ vi.restoreAllMocks();
39
+ });
40
+ // Settlement is scheduled with setTimeout(0) after the response is sent.
41
+ const flushSettlement = () => new Promise((resolve) => setTimeout(resolve, 10));
42
+ describe("POST /v1/run/resource", () => {
43
+ function voucherFor(functionName = "temperature", overrides = {}) {
44
+ const voucher = callerInvoker.createPaymentVoucher(RECIPIENT, COST, weather.path, weather.name, functionName, parameters);
45
+ if (Object.keys(overrides).length === 0) {
46
+ return voucher;
47
+ }
48
+ // Re-sign so the only defect is the overridden field.
49
+ const resigned = { ...voucher, ...overrides };
50
+ return { ...resigned, signature: signData(paymentVoucherPayload(resigned), caller.privateKey) };
51
+ }
52
+ function runResource(body) {
53
+ return fastify.inject({
54
+ method: "POST",
55
+ url: "/v1/run/resource",
56
+ payload: { resourcePath: weather.path, resourceName: weather.name, functionName: "temperature", parameters, ...body },
57
+ });
58
+ }
59
+ test("runs a paid function with a valid voucher and settles it", async () => {
60
+ const paymentVoucher = voucherFor();
61
+ const response = await runResource({ paymentVoucher });
62
+ expect(response.statusCode).toBe(200);
63
+ expect(response.json()).toEqual({ value: 12, error: null });
64
+ await flushSettlement();
65
+ expect(settlePayment).toHaveBeenCalledExactlyOnceWith({ voucher: paymentVoucher });
66
+ });
67
+ test("refuses a voucher from a blocked caller, even for an unrestricted function", async () => {
68
+ await service.grantService.block(caller.publicKey);
69
+ const response = await runResource({ paymentVoucher: voucherFor() });
70
+ expect(response.statusCode).toBe(403);
71
+ expect(response.json()).toEqual({ error: "Caller is blocked" });
72
+ await flushSettlement();
73
+ expect(settlePayment).not.toHaveBeenCalled();
74
+ });
75
+ test("accepts a voucher that pays more than the cost", async () => {
76
+ const response = await runResource({ paymentVoucher: voucherFor("temperature", { amount: COST + 1 }) });
77
+ expect(response.statusCode).toBe(200);
78
+ });
79
+ test("rejects a paid function without a voucher", async () => {
80
+ const response = await runResource({});
81
+ expect(response.statusCode).toBe(402);
82
+ await flushSettlement();
83
+ expect(settlePayment).not.toHaveBeenCalled();
84
+ });
85
+ test.each([
86
+ ["pays less than the cost", { amount: COST - 1 }],
87
+ ["pays a different recipient", { recipientAddress: "11111111111111111111111111111111" }],
88
+ ["has expired", { expiresAt: new Date(Date.now() - 1000).toISOString() }],
89
+ ["has an unparseable expiry", { expiresAt: "not-a-date" }],
90
+ ])("rejects a voucher that %s", async (_, overrides) => {
91
+ const response = await runResource({ paymentVoucher: voucherFor("temperature", overrides) });
92
+ expect(response.statusCode).toBe(400);
93
+ expect(response.json()).toEqual({ error: "Invalid payment voucher" });
94
+ await flushSettlement();
95
+ expect(settlePayment).not.toHaveBeenCalled();
96
+ });
97
+ test("rejects a voucher whose parameters were tampered with", async () => {
98
+ const response = await runResource({ paymentVoucher: voucherFor(), parameters: [{ name: "city", value: "Bergen" }] });
99
+ expect(response.statusCode).toBe(400);
100
+ });
101
+ test("rejects a voucher issued for a different function", async () => {
102
+ const paymentVoucher = callerInvoker.createPaymentVoucher(RECIPIENT, COST, weather.path, weather.name, "humidity", parameters);
103
+ const response = await runResource({ paymentVoucher });
104
+ expect(response.statusCode).toBe(400);
105
+ });
106
+ test("rejects a voucher with a forged signature", async () => {
107
+ const paymentVoucher = { ...voucherFor(), amount: COST * 2 };
108
+ const response = await runResource({ paymentVoucher });
109
+ expect(response.statusCode).toBe(400);
110
+ });
111
+ test("rejects a voucher with a malformed public key", async () => {
112
+ const response = await runResource({ paymentVoucher: { ...voucherFor(), publicKey: "not-a-key" } });
113
+ expect(response.statusCode).toBe(400);
114
+ });
115
+ test("rejects a voucher with missing fields", async () => {
116
+ const { signature: _, ...unsigned } = voucherFor();
117
+ const response = await runResource({ paymentVoucher: unsigned });
118
+ expect(response.statusCode).toBe(400);
119
+ });
120
+ test("runs a free function and ignores any voucher sent with it", async () => {
121
+ const response = await runResource({ functionName: "humidity", paymentVoucher: voucherFor("humidity") });
122
+ expect(response.statusCode).toBe(200);
123
+ expect(response.json()).toEqual({ value: 80, error: null });
124
+ await flushSettlement();
125
+ expect(settlePayment).not.toHaveBeenCalled();
126
+ });
127
+ test("rejects an unknown function without settling the voucher", async () => {
128
+ const response = await runResource({ functionName: "missing", paymentVoucher: voucherFor("missing") });
129
+ expect(response.statusCode).toBe(400);
130
+ expect(response.json()).toEqual({ error: "Function not found" });
131
+ await flushSettlement();
132
+ expect(settlePayment).not.toHaveBeenCalled();
133
+ });
134
+ test("does not settle when the invoke returns an error", async () => {
135
+ vi.spyOn(service.resourceInvoker, "invoke").mockResolvedValueOnce({ value: null, error: { message: "boom" } });
136
+ const response = await runResource({ paymentVoucher: voucherFor() });
137
+ expect(response.statusCode).toBe(200);
138
+ await flushSettlement();
139
+ expect(settlePayment).not.toHaveBeenCalled();
140
+ });
141
+ test("rejects an unknown resource", async () => {
142
+ const response = await runResource({ resourceName: "Missing" });
143
+ expect(response.statusCode).toBe(400);
144
+ expect(response.json()).toEqual({ error: "Resource not found" });
145
+ });
146
+ test("rejects a body without the call fields", async () => {
147
+ const response = await fastify.inject({ method: "POST", url: "/v1/run/resource", payload: {} });
148
+ expect(response.statusCode).toBe(400);
149
+ });
150
+ });
151
+ describe("POST /v1/run", () => {
152
+ function run(authorization, payload = { source: temperatureSource }) {
153
+ return fastify.inject({
154
+ method: "POST",
155
+ url: "/v1/run",
156
+ headers: authorization === undefined ? {} : { authorization },
157
+ payload: payload,
158
+ });
159
+ }
160
+ test("runs the request with the configured API key", async () => {
161
+ const response = await run("Bearer secret");
162
+ expect(response.statusCode).toBe(200);
163
+ // RequestScript resource calls evaluate to the invoker's { value, error } result.
164
+ expect(response.json()).toEqual({ returnValue: { value: 12, error: null } });
165
+ });
166
+ test("requires an Authorization header", async () => {
167
+ const response = await run();
168
+ expect(response.statusCode).toBe(401);
169
+ expect(response.json()).toEqual({ error: "Authorization header is required" });
170
+ });
171
+ test.each([
172
+ ["the wrong key", "Bearer wrong"],
173
+ ["a key that is a prefix of the real one", "Bearer secre"],
174
+ ["no token", "Bearer"],
175
+ ])("rejects %s", async (_, authorization) => {
176
+ const response = await run(authorization);
177
+ expect(response.statusCode).toBe(401);
178
+ expect(response.json()).toEqual({ error: "Invalid API Key" });
179
+ });
180
+ test.each([
181
+ ["unset", undefined],
182
+ ["empty", ""],
183
+ ])("rejects every request when the API key is %s", async (_, apiKey) => {
184
+ config.apiKey = apiKey;
185
+ for (const authorization of ["Bearer", "Bearer ", "Bearer undefined"]) {
186
+ const response = await run(authorization);
187
+ expect(response.statusCode).toBe(401);
188
+ }
189
+ });
190
+ test("authenticates before validating the body", async () => {
191
+ expect((await run("Bearer wrong", {})).statusCode).toBe(401);
192
+ expect((await run("Bearer secret", {})).statusCode).toBe(400);
193
+ });
194
+ test("rejects a script that doesn't parse with 400", async () => {
195
+ const response = await run("Bearer secret", { source: "not a script" });
196
+ expect(response.statusCode).toBe(400);
197
+ expect(response.json()).toMatchObject({ error: "Invalid script" });
198
+ });
199
+ });
200
+ describe("GET /v1/self", () => {
201
+ test("describes this node from its config", async () => {
202
+ const response = await fastify.inject({ method: "GET", url: "/v1/self" });
203
+ expect(response.json()).toEqual({ baseUrl: config.baseUrl, publicKey: config.publicKey, name: config.baseUrl });
204
+ });
205
+ });
206
+ describe("PUT /v1/peers", () => {
207
+ const keys = generateBase64KeyPair();
208
+ const peer = { baseUrl: "http://peer.example", name: "http://peer.example", publicKey: keys.publicKey };
209
+ function announce(announced, privateKey = keys.privateKey, expiry = inMinutes(10), payload = peerAnnouncementPayload(announced, expiry)) {
210
+ return fastify.inject({
211
+ method: "PUT",
212
+ url: "/v1/peers",
213
+ payload: { ...announced, expiry, signature: signData(payload, privateKey) },
214
+ });
215
+ }
216
+ const peers = async () => (await fastify.inject({ method: "GET", url: "/v1/peers" })).json().peers;
217
+ test("saves a peer with a valid announcement", async () => {
218
+ const response = await announce(peer);
219
+ expect(response.statusCode).toBe(200);
220
+ expect(await peers()).toEqual([peer]);
221
+ });
222
+ test("lets a known peer change its base URL", async () => {
223
+ await announce(peer);
224
+ const moved = { ...peer, baseUrl: "http://moved.example", name: "http://moved.example" };
225
+ const response = await announce(moved);
226
+ expect(response.statusCode).toBe(200);
227
+ expect(await peers()).toEqual([moved]);
228
+ });
229
+ test("rejects an announcement signed by a different key", async () => {
230
+ const response = await announce(peer, generateBase64KeyPair().privateKey);
231
+ expect(response.statusCode).toBe(400);
232
+ expect(await peers()).toEqual([]);
233
+ });
234
+ test("rejects an announcement whose signature doesn't cover the public key", async () => {
235
+ const expiry = inMinutes(10);
236
+ const response = await announce(peer, keys.privateKey, expiry, { baseUrl: peer.baseUrl, name: peer.name, expiry });
237
+ expect(response.statusCode).toBe(400);
238
+ });
239
+ test.each([
240
+ ["expired", inMinutes(-1)],
241
+ ["valid too far into the future", inMinutes(24 * 60)],
242
+ ["unparseable", "soon"],
243
+ ])("rejects an announcement whose expiry is %s", async (_, expiry) => {
244
+ const response = await announce(peer, keys.privateKey, expiry);
245
+ expect(response.statusCode).toBe(400);
246
+ });
247
+ test("rejects another key claiming a known peer's base URL", async () => {
248
+ await announce(peer);
249
+ const impostor = generateBase64KeyPair();
250
+ const response = await announce({ ...peer, publicKey: impostor.publicKey }, impostor.privateKey);
251
+ expect(response.statusCode).toBe(409);
252
+ expect(await peers()).toEqual([peer]);
253
+ });
254
+ test("rejects an announcement with missing fields", async () => {
255
+ const response = await fastify.inject({ method: "PUT", url: "/v1/peers", payload: { baseUrl: peer.baseUrl } });
256
+ expect(response.statusCode).toBe(400);
257
+ });
258
+ });
259
+ describe("PUT /v1/all-resources", () => {
260
+ const keys = generateBase64KeyPair();
261
+ const peer = { baseUrl: "http://peer.example", name: "http://peer.example", publicKey: keys.publicKey };
262
+ const forecast = (baseUrl = peer.baseUrl, name = "Forecast") => ({
263
+ id: 7,
264
+ baseUrl,
265
+ metadata: {},
266
+ path: "com.peer",
267
+ name,
268
+ functions: [{
269
+ name: "tomorrow",
270
+ parameters: [{ name: "city", type: "string" }],
271
+ returnType: "string",
272
+ exec: async () => "sunny",
273
+ paymentManifest: { costLamports: 500, recipientAddress: RECIPIENT },
274
+ }],
275
+ });
276
+ function broadcast(resources, privateKey = keys.privateKey, publicKey = keys.publicKey) {
277
+ const expiry = inMinutes(10);
278
+ // Serialize as the sender does, so the receiver sees the same JSON the signature covers.
279
+ const sent = JSON.parse(JSON.stringify(resources));
280
+ return fastify.inject({
281
+ method: "PUT",
282
+ url: "/v1/all-resources",
283
+ payload: { publicKey, resources: sent, expiry, signature: signData(resourceAnnouncementPayload(sent, expiry), privateKey) },
284
+ });
285
+ }
286
+ beforeEach(async () => {
287
+ const expiry = inMinutes(10);
288
+ await service.peerRepository.announce(peer, signData(peerAnnouncementPayload(peer, expiry), keys.privateKey), expiry);
289
+ });
290
+ test("saves a known peer's resources, including payment manifests", async () => {
291
+ const response = await broadcast([forecast()]);
292
+ expect(response.statusCode).toBe(200);
293
+ expect(response.json()).toEqual({ success: true, rejected: [] });
294
+ const resources = (await fastify.inject({ method: "GET", url: "/v1/resources" })).json().resources;
295
+ expect(resources.find((r) => r.name === "Forecast")).toMatchObject({
296
+ baseUrl: peer.baseUrl,
297
+ functions: [{ name: "tomorrow", paymentManifest: { costLamports: 500, recipientAddress: RECIPIENT } }],
298
+ });
299
+ });
300
+ test("rejects a broadcast from an unknown peer", async () => {
301
+ const stranger = generateBase64KeyPair();
302
+ const response = await broadcast([forecast()], stranger.privateKey, stranger.publicKey);
303
+ expect(response.statusCode).toBe(400);
304
+ expect(response.json()).toEqual({ error: "Peer not found" });
305
+ });
306
+ test("rejects a broadcast not signed by the peer", async () => {
307
+ const response = await broadcast([forecast()], generateBase64KeyPair().privateKey);
308
+ expect(response.statusCode).toBe(400);
309
+ expect(response.json()).toEqual({ error: "Invalid signature" });
310
+ });
311
+ test("rejects resources served from another base URL or hosted by this node", async () => {
312
+ const response = await broadcast([forecast("http://elsewhere.example"), { ...weatherResource(peer.baseUrl) }]);
313
+ expect(response.json()).toEqual({ success: true, rejected: ["com.peer.Forecast", "com.example.Weather"] });
314
+ expect(await service.resourceRepository.getResourceFunction("com.peer", "Forecast", "tomorrow")).toBeUndefined();
315
+ });
316
+ });
317
+ describe("pagination", () => {
318
+ const get = (url) => fastify.inject({ method: "GET", url });
319
+ beforeEach(async () => {
320
+ for (let i = 0; i < 5; i++) {
321
+ const baseUrl = `http://peer${i}.example`;
322
+ await service.peerRepository.create({ baseUrl, name: baseUrl, publicKey: `key-${i}` });
323
+ await service.resourceRepository.learn({ id: 0, baseUrl, metadata: {}, path: "com.peer", name: `Learned${i}`, functions: [] });
324
+ }
325
+ });
326
+ test("GET /v1/peers returns pages in insertion order", async () => {
327
+ const first = (await get("/v1/peers?limit=2")).json();
328
+ const last = (await get("/v1/peers?offset=4&limit=2")).json();
329
+ expect(first.peers.map((p) => p.publicKey)).toEqual(["key-0", "key-1"]);
330
+ expect(first.nextOffset).toBe(2);
331
+ expect(last.peers.map((p) => p.publicKey)).toEqual(["key-4"]);
332
+ expect(last.nextOffset).toBeNull();
333
+ });
334
+ test("GET /v1/resources lists hosted resources, then learned ones", async () => {
335
+ const first = (await get("/v1/resources?limit=2")).json();
336
+ const second = (await get(`/v1/resources?offset=${first.nextOffset}&limit=10`)).json();
337
+ expect(first.resources.map((r) => r.name)).toEqual(["Weather", "Learned0"]);
338
+ expect(second.resources.map((r) => r.name)).toEqual(["Learned1", "Learned2", "Learned3", "Learned4"]);
339
+ expect(second.nextOffset).toBeNull();
340
+ });
341
+ test("a page ending exactly at the last item has no next page", async () => {
342
+ expect((await get("/v1/peers?offset=3&limit=2")).json().nextOffset).toBeNull();
343
+ });
344
+ test("defaults to the first page", async () => {
345
+ expect((await get("/v1/peers")).json()).toMatchObject({ nextOffset: null, peers: expect.any(Array) });
346
+ });
347
+ test.each([
348
+ ["a limit above the maximum", "limit=501"],
349
+ ["a zero limit", "limit=0"],
350
+ ["a negative offset", "offset=-1"],
351
+ ["a non-numeric offset", "offset=abc"],
352
+ ])("rejects %s", async (_, query) => {
353
+ expect((await get(`/v1/peers?${query}`)).statusCode).toBe(400);
354
+ expect((await get(`/v1/resources?${query}`)).statusCode).toBe(400);
355
+ });
356
+ });
357
+ });
358
+ describe("restricted functions", () => {
359
+ const caller = generateBase64KeyPair();
360
+ const callerInvoker = new CombinationResourceInvoker(undefined, 100_000, caller.publicKey, caller.privateKey);
361
+ const restricted = {
362
+ id: 1,
363
+ baseUrl: "http://127.0.0.1:3002",
364
+ metadata: {},
365
+ path: "com.example",
366
+ name: "Vault",
367
+ functions: [
368
+ { name: "open", parameters: [{ name: "city", type: "string" }], returnType: "int32", exec: async () => 7, restricted: true },
369
+ { name: "premium", parameters: [{ name: "city", type: "string" }], returnType: "int32", exec: async () => 9, restricted: true, paymentManifest: { costLamports: COST, recipientAddress: RECIPIENT } },
370
+ ],
371
+ };
372
+ const call = (functionName) => ({ resourcePath: "com.example", resourceName: "Vault", functionName, parameters });
373
+ let db;
374
+ let service;
375
+ let callerGrants;
376
+ let callerDb;
377
+ let fastify;
378
+ let grant;
379
+ beforeEach(async () => {
380
+ db = await openDatabase(":memory:");
381
+ service = createService(db, [restricted], testConfig());
382
+ vi.spyOn(service.operatorClient, "settlePayment").mockResolvedValue(undefined);
383
+ callerDb = await openDatabase(":memory:");
384
+ callerGrants = new GrantService(new GrantRepository(callerDb), { baseUrl: "http://caller", ...caller });
385
+ grant = await service.grantService.issue(caller.publicKey, [{ resourcePath: "com.example", resourceName: "Vault" }], { lifetimeMs: 3_600_000 });
386
+ fastify = Fastify();
387
+ await fastify.register(routes, { prefix: "/v1", service });
388
+ await fastify.ready();
389
+ });
390
+ afterEach(async () => {
391
+ await fastify.close();
392
+ await db.close();
393
+ await callerDb.close();
394
+ vi.restoreAllMocks();
395
+ });
396
+ const runResource = (body) => fastify.inject({ method: "POST", url: "/v1/run/resource", payload: body });
397
+ test("is announced as restricted", async () => {
398
+ const response = await fastify.inject({ method: "GET", url: "/v1/resources" });
399
+ expect(response.json().resources[0].functions).toEqual([
400
+ expect.objectContaining({ name: "open", restricted: true }),
401
+ expect.objectContaining({ name: "premium", restricted: true }),
402
+ ]);
403
+ });
404
+ test("requires an authorization, with no grant endpoint when the node takes no requests", async () => {
405
+ const response = await runResource(call("open"));
406
+ expect(response.statusCode).toBe(403);
407
+ expect(response.json()).toEqual({ error: "Authorization is required" });
408
+ });
409
+ test("refuses grant requests when the node has no approval hook", async () => {
410
+ const response = await fastify.inject({ method: "POST", url: "/v1/grants/request", payload: { publicKey: caller.publicKey, capabilities: [{ resourcePath: "*" }], nonce: "n", expiresAt: inMinutes(1), signature: "s" } });
411
+ expect(response.statusCode).toBe(403);
412
+ expect(response.json()).toEqual({ error: "This node does not issue grants on request" });
413
+ });
414
+ describe("with an approval hook", () => {
415
+ const vaultCapability = { resourcePath: "com.example", resourceName: "Vault" };
416
+ beforeEach(async () => {
417
+ await fastify.close();
418
+ await db.close();
419
+ db = await openDatabase(":memory:");
420
+ service = createService(db, [restricted], testConfig(), {
421
+ approveGrant: ({ details }) => details?.invite === "letmein" ? { lifetimeMs: 3_600_000 } : undefined,
422
+ });
423
+ fastify = Fastify();
424
+ await fastify.register(routes, { prefix: "/v1", service });
425
+ await fastify.ready();
426
+ });
427
+ function grantRequest(details) {
428
+ const unsigned = { publicKey: caller.publicKey, capabilities: [vaultCapability], details, nonce: crypto.randomUUID(), expiresAt: inMinutes(1) };
429
+ return { ...unsigned, signature: signData(grantRequestPayload(unsigned), caller.privateKey) };
430
+ }
431
+ test("points a caller without a usable grant at the request route", async () => {
432
+ const missing = await runResource(call("open"));
433
+ expect(missing.json()).toEqual({ error: "Authorization is required", grantEndpoint: "http://127.0.0.1:3002/v1/grants/request" });
434
+ const invalid = await runResource({ ...call("open"), authorization: { grants: [] } });
435
+ expect(invalid.json()).toEqual({ error: "Invalid authorization", grantEndpoint: "http://127.0.0.1:3002/v1/grants/request" });
436
+ });
437
+ test("issues an approved grant that then authorizes calls", async () => {
438
+ const response = await fastify.inject({ method: "POST", url: "/v1/grants/request", payload: grantRequest({ invite: "letmein" }) });
439
+ expect(response.statusCode).toBe(200);
440
+ const { grants } = response.json();
441
+ expect(grants).toEqual([expect.objectContaining({ audience: caller.publicKey, capabilities: [vaultCapability] })]);
442
+ const run = await runResource({ ...call("open"), authorization: callerGrants.authorize(grants, call("open")) });
443
+ expect(run.json()).toEqual({ value: 7, error: null });
444
+ });
445
+ test("refuses a request the hook declines", async () => {
446
+ const response = await fastify.inject({ method: "POST", url: "/v1/grants/request", payload: grantRequest({ invite: "wrong" }) });
447
+ expect(response.statusCode).toBe(403);
448
+ expect(response.json()).toEqual({ error: "Grant request refused" });
449
+ });
450
+ test("answers 500 when the approval hook returns an invalid approval", async () => {
451
+ await fastify.close();
452
+ await db.close();
453
+ db = await openDatabase(":memory:");
454
+ service = createService(db, [restricted], testConfig(), { approveGrant: () => ({ lifetimeMs: -1 }) });
455
+ fastify = Fastify();
456
+ await fastify.register(routes, { prefix: "/v1", service });
457
+ await fastify.ready();
458
+ const response = await fastify.inject({ method: "POST", url: "/v1/grants/request", payload: grantRequest(null) });
459
+ expect(response.statusCode).toBe(500);
460
+ expect(response.json()).toEqual({ error: "Internal server error" });
461
+ });
462
+ test("rejects a request with missing fields or no capabilities", async () => {
463
+ const { capabilities: _, ...withoutCapabilities } = grantRequest(null);
464
+ expect((await fastify.inject({ method: "POST", url: "/v1/grants/request", payload: withoutCapabilities })).statusCode).toBe(400);
465
+ expect((await fastify.inject({ method: "POST", url: "/v1/grants/request", payload: { ...grantRequest(null), capabilities: [] } })).statusCode).toBe(400);
466
+ });
467
+ });
468
+ test("runs with a valid authorization and refuses it a second time", async () => {
469
+ const body = { ...call("open"), authorization: callerGrants.authorize(grant, call("open")) };
470
+ const first = await runResource(body);
471
+ expect(first.statusCode).toBe(200);
472
+ expect(first.json()).toEqual({ value: 7, error: null });
473
+ const replay = await runResource(body);
474
+ expect(replay.statusCode).toBe(403);
475
+ expect(replay.json()).toEqual({ error: "Invalid authorization" });
476
+ });
477
+ test("refuses an authorization signed for another call", async () => {
478
+ const response = await runResource({ ...call("open"), parameters: [{ name: "city", value: "Paris" }], authorization: callerGrants.authorize(grant, call("open")) });
479
+ expect(response.statusCode).toBe(403);
480
+ });
481
+ test("refuses duplicate or undeclared parameters before checking the grant", async () => {
482
+ const constrained = await service.grantService.issue(caller.publicKey, [{ resourcePath: "com.example", resourceName: "Vault", constraints: { city: "Oslo" } }], { lifetimeMs: 3_600_000 });
483
+ for (const extra of [[{ name: "city", value: "Oslo" }, { name: "city", value: "Paris" }], [{ name: "other", value: 1 }, { name: "city", value: "Oslo" }]]) {
484
+ const smuggled = { ...call("open"), parameters: extra };
485
+ const response = await runResource({ ...smuggled, authorization: callerGrants.authorize(constrained, smuggled) });
486
+ expect(response.statusCode).toBe(400);
487
+ }
488
+ });
489
+ test("refuses a malformed authorization", async () => {
490
+ const response = await runResource({ ...call("open"), authorization: { grants: "none" } });
491
+ expect(response.statusCode).toBe(403);
492
+ });
493
+ test("checks the authorization before the voucher, and needs both for a paid function", async () => {
494
+ const paymentVoucher = callerInvoker.createPaymentVoucher(RECIPIENT, COST, "com.example", "Vault", "premium", parameters);
495
+ const unauthorized = await runResource({ ...call("premium"), paymentVoucher });
496
+ expect(unauthorized.statusCode).toBe(403);
497
+ await new Promise((resolve) => setTimeout(resolve, 10));
498
+ expect(service.operatorClient.settlePayment).not.toHaveBeenCalled();
499
+ const unpaid = await runResource({ ...call("premium"), authorization: callerGrants.authorize(grant, call("premium")) });
500
+ expect(unpaid.statusCode).toBe(402);
501
+ const paid = await runResource({ ...call("premium"), paymentVoucher, authorization: callerGrants.authorize(grant, call("premium")) });
502
+ expect(paid.statusCode).toBe(200);
503
+ expect(paid.json()).toEqual({ value: 9, error: null });
504
+ });
505
+ describe("admin routes", () => {
506
+ const admin = (method, url, payload, token = "secret") => fastify.inject({ method, url, payload, headers: { authorization: `Bearer ${token}` } });
507
+ test.each([
508
+ ["PUT", "/v1/admin/revoked-grants/some-id"],
509
+ ["GET", "/v1/admin/blocked-callers"],
510
+ ["PUT", `/v1/admin/blocked-callers/${caller.publicKey}`],
511
+ ["DELETE", `/v1/admin/blocked-callers/${caller.publicKey}`],
512
+ ])("%s %s requires the API key", async (method, url) => {
513
+ expect((await fastify.inject({ method, url })).statusCode).toBe(401);
514
+ expect((await admin(method, url, undefined, "wrong")).statusCode).toBe(401);
515
+ });
516
+ test("revoking a grant refuses calls made with it", async () => {
517
+ const response = await admin("PUT", `/v1/admin/revoked-grants/${grant[0].id}`);
518
+ expect(response.json()).toEqual({ success: true });
519
+ const run = await runResource({ ...call("open"), authorization: callerGrants.authorize(grant, call("open")) });
520
+ expect(run.statusCode).toBe(403);
521
+ });
522
+ test("?issuer= revokes another issuer's grant, leaving this node's grant with the same id alone", async () => {
523
+ expect((await admin("PUT", `/v1/admin/revoked-grants/${grant[0].id}?issuer=${caller.publicKey}`)).json()).toEqual({ success: true });
524
+ expect((await runResource({ ...call("open"), authorization: callerGrants.authorize(grant, call("open")) })).statusCode).toBe(200);
525
+ });
526
+ test("blocking a caller refuses its calls until it is unblocked", async () => {
527
+ expect((await admin("PUT", `/v1/admin/blocked-callers/${caller.publicKey}`, { reason: "abuse" })).json()).toEqual({ success: true });
528
+ expect((await runResource({ ...call("open"), authorization: callerGrants.authorize(grant, call("open")) })).statusCode).toBe(403);
529
+ const listed = await admin("GET", "/v1/admin/blocked-callers");
530
+ expect(listed.json()).toEqual({ callers: [{ publicKey: caller.publicKey, reason: "abuse", blockedAt: expect.any(String) }], nextOffset: null });
531
+ expect((await admin("DELETE", `/v1/admin/blocked-callers/${caller.publicKey}`)).json()).toEqual({ success: true });
532
+ expect((await admin("DELETE", `/v1/admin/blocked-callers/${caller.publicKey}`)).statusCode).toBe(404);
533
+ expect((await runResource({ ...call("open"), authorization: callerGrants.authorize(grant, call("open")) })).statusCode).toBe(200);
534
+ });
535
+ test("blocks without a body, and refuses a bad key or reason", async () => {
536
+ expect((await admin("PUT", `/v1/admin/blocked-callers/${caller.publicKey}`)).statusCode).toBe(200);
537
+ expect((await admin("PUT", "/v1/admin/blocked-callers/not-a-key")).json()).toEqual({ error: "Invalid public key" });
538
+ expect((await admin("PUT", `/v1/admin/blocked-callers/${caller.publicKey}`, { reason: 5 })).json()).toEqual({ error: "reason must be a string" });
539
+ });
540
+ });
541
+ describe("POST /v1/grants/renew", () => {
542
+ function renewalRequest(grantId, keys = caller) {
543
+ const unsigned = { grantId, publicKey: keys.publicKey, nonce: crypto.randomUUID(), expiresAt: inMinutes(1) };
544
+ return { ...unsigned, signature: signData(grantRenewalPayload(unsigned), keys.privateKey) };
545
+ }
546
+ test("returns a fresh copy of the grant to its audience", async () => {
547
+ const response = await fastify.inject({ method: "POST", url: "/v1/grants/renew", payload: renewalRequest(grant[0].id) });
548
+ expect(response.statusCode).toBe(200);
549
+ const { grants } = response.json();
550
+ expect(grants).toHaveLength(1);
551
+ expect(grants[0]).toMatchObject({ id: grant[0].id, audience: caller.publicKey, capabilities: grant[0].capabilities });
552
+ });
553
+ test("refuses another key, an unknown grant and a revoked grant", async () => {
554
+ const renew = (payload) => fastify.inject({ method: "POST", url: "/v1/grants/renew", payload: payload });
555
+ expect((await renew(renewalRequest(grant[0].id, generateBase64KeyPair()))).statusCode).toBe(403);
556
+ expect((await renew(renewalRequest("unknown"))).statusCode).toBe(403);
557
+ await service.grantService.revoke(grant[0].id);
558
+ expect((await renew(renewalRequest(grant[0].id))).statusCode).toBe(403);
559
+ });
560
+ test("rejects a body with missing fields", async () => {
561
+ const response = await fastify.inject({ method: "POST", url: "/v1/grants/renew", payload: { grantId: grant[0].id } });
562
+ expect(response.statusCode).toBe(400);
563
+ });
564
+ });
565
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=server.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.test.d.ts","sourceRoot":"","sources":["../../src/tests/server.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,37 @@
1
+ import { afterEach, describe, expect, test, vi } from "vitest";
2
+ import { DEFAULT_COST_LIMIT_LAMPORTS, resolveConfig } from "../server.js";
3
+ import { weatherResource } from "./helpers.js";
4
+ describe("resolveConfig", () => {
5
+ const keys = { baseUrl: "http://node.example", publicKey: "public", privateKey: "private" };
6
+ afterEach(() => {
7
+ vi.unstubAllEnvs();
8
+ });
9
+ test("prefers options over the environment", () => {
10
+ vi.stubEnv("BASE_URL", "http://env.example");
11
+ vi.stubEnv("API_KEY", "env-key");
12
+ expect(resolveConfig({ ...keys, apiKey: "option-key" })).toMatchObject({ baseUrl: keys.baseUrl, apiKey: "option-key" });
13
+ });
14
+ test("falls back to the environment", () => {
15
+ vi.stubEnv("BASE_URL", "http://env.example");
16
+ vi.stubEnv("SIGNING_PUBLIC_KEY", "env-public");
17
+ vi.stubEnv("SIGNING_PRIVATE_KEY", "env-private");
18
+ vi.stubEnv("COST_LIMIT_LAMPORTS", "500");
19
+ expect(resolveConfig({})).toMatchObject({ baseUrl: "http://env.example", publicKey: "env-public", privateKey: "env-private", costLimitLamports: 500 });
20
+ });
21
+ test("defaults the cost limit", () => {
22
+ vi.stubEnv("COST_LIMIT_LAMPORTS", undefined);
23
+ expect(resolveConfig(keys).costLimitLamports).toBe(DEFAULT_COST_LIMIT_LAMPORTS);
24
+ });
25
+ test.each([-1, 1.5, Number.NaN])("rejects a cost limit of %s", (costLimitLamports) => {
26
+ expect(() => resolveConfig({ ...keys, costLimitLamports })).toThrow("costLimitLamports");
27
+ });
28
+ test("requires the signing keys and base URL", () => {
29
+ vi.stubEnv("SIGNING_PRIVATE_KEY", undefined);
30
+ expect(() => resolveConfig({ baseUrl: keys.baseUrl, publicKey: keys.publicKey })).toThrow("SIGNING_PRIVATE_KEY is required");
31
+ });
32
+ test("requires an operator URL only when hosting paid functions", () => {
33
+ vi.stubEnv("OPERATOR_URL", undefined);
34
+ expect(resolveConfig(keys, []).operatorUrl).toBeUndefined();
35
+ expect(() => resolveConfig(keys, [weatherResource()])).toThrow("OPERATOR_URL is required");
36
+ });
37
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=startup.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"startup.test.d.ts","sourceRoot":"","sources":["../../src/tests/startup.test.ts"],"names":[],"mappings":""}