stellarium-ts 0.17.0 → 0.19.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.
@@ -1,6 +1,8 @@
1
+ import { generateKeyPairSync } from "node:crypto";
1
2
  import Fastify from "fastify";
3
+ import { createLocalJWKSet, exportJWK, SignJWT } from "jose";
2
4
  import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
3
- import { signData } from "../crypto/utils.js";
5
+ import { safeVerifyData, signData } from "../crypto/utils.js";
4
6
  import { openDatabase } from "../db/db.js";
5
7
  import { grantRenewalPayload, grantRequestPayload } from "../grant/grant.js";
6
8
  import { GrantRepository } from "../grant/repository.js";
@@ -184,7 +186,7 @@ describe("routes", () => {
184
186
  const response = await run("Bearer secret");
185
187
  expect(response.statusCode).toBe(200);
186
188
  // RequestScript resource calls evaluate to the invoker's { value, error } result.
187
- expect(response.json()).toEqual({ returnValue: { value: 12, error: null } });
189
+ expect(response.json()).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: 12, error: null } });
188
190
  });
189
191
  test("requires an Authorization header", async () => {
190
192
  const response = await run();
@@ -219,6 +221,116 @@ describe("routes", () => {
219
221
  expect(response.statusCode).toBe(400);
220
222
  expect(response.json()).toMatchObject({ error: "Invalid script" });
221
223
  });
224
+ test("answers 422 for a script that fails while running", async () => {
225
+ const response = await run("Bearer secret", { source: "request R {\n return 1 / 0\n}" });
226
+ expect(response.statusCode).toBe(422);
227
+ expect(response.json()).toMatchObject({ error: "Script failed", details: expect.stringContaining("Division by zero"), usage: { tokens: 4 } });
228
+ });
229
+ describe("with run tokens", () => {
230
+ const operatorKeys = generateKeyPairSync("ed25519");
231
+ let tokenService;
232
+ let tokenApp;
233
+ let authorizeRun;
234
+ let captureRun;
235
+ let releaseRun;
236
+ beforeEach(async () => {
237
+ const jwk = await exportJWK(operatorKeys.publicKey);
238
+ tokenService = createService(db, [weather], config, { runTokenKeys: createLocalJWKSet({ keys: [{ ...jwk, alg: "EdDSA" }] }) });
239
+ authorizeRun = vi.spyOn(tokenService.operatorClient, "authorizeRun").mockResolvedValue({ ok: true, heldMicroUsd: 50_000, maxTokens: 9 });
240
+ captureRun = vi.spyOn(tokenService.operatorClient, "captureRun").mockResolvedValue(undefined);
241
+ releaseRun = vi.spyOn(tokenService.operatorClient, "releaseRun").mockResolvedValue(undefined);
242
+ vi.spyOn(tokenService.operatorClient, "authorize").mockResolvedValue({ ok: true });
243
+ vi.spyOn(tokenService.operatorClient, "capture").mockResolvedValue(undefined);
244
+ tokenApp = Fastify();
245
+ await tokenApp.register(routes, { prefix: "/v1", service: tokenService });
246
+ await tokenApp.ready();
247
+ });
248
+ afterEach(async () => {
249
+ await tokenApp.close();
250
+ });
251
+ /** A token the operator would issue, for `organizationId`'s client on this node unless `audience` says otherwise. */
252
+ const runToken = (organizationId, options = {}) => new SignJWT({ org: organizationId })
253
+ .setProtectedHeader({ alg: "EdDSA" })
254
+ .setIssuer("stellarium")
255
+ .setSubject("cli_test")
256
+ .setAudience(options.audience ?? config.publicKey)
257
+ .setIssuedAt()
258
+ .setExpirationTime(options.expiresIn ?? "15m")
259
+ .sign(options.key ?? operatorKeys.privateKey);
260
+ const runWith = async (token, source = temperatureSource) => tokenApp.inject({
261
+ method: "POST", url: "/v1/run", headers: { authorization: `Bearer ${token}` }, payload: { source },
262
+ });
263
+ test("bills another organization's run, and charges its call to this node's paid function to the run", async () => {
264
+ const token = await runToken("org_caller");
265
+ const response = await runWith(token);
266
+ expect(response.statusCode).toBe(200);
267
+ expect(response.json()).toEqual({ returnValue: { value: 12, error: null }, usage: { tokens: 17 } });
268
+ const runId = authorizeRun.mock.calls[0][0].runId;
269
+ expect(authorizeRun).toHaveBeenCalledWith({ token, runId: expect.any(String), source: temperatureSource });
270
+ // The local paid function signed a voucher for the run, paying this node's organization.
271
+ const [voucher] = tokenService.operatorClient.authorize.mock.calls[0];
272
+ expect(voucher).toMatchObject({ runId, recipientOrganizationId: ORGANIZATION, amount: COST, publicKey: config.publicKey });
273
+ expect(safeVerifyData(paymentVoucherPayload(voucher), voucher.publicKey, voucher.signature)).toBe(true);
274
+ await vi.waitFor(() => expect(captureRun).toHaveBeenCalledWith({ runId, tokens: 17 }));
275
+ });
276
+ test("runs this node's own organization's tokens free, without the operator", async () => {
277
+ const response = await runWith(await runToken(ORGANIZATION));
278
+ expect(response.json()).toEqual({ returnValue: { value: 12, error: null }, usage: { tokens: 17 } });
279
+ expect(authorizeRun).not.toHaveBeenCalled();
280
+ expect(tokenService.operatorClient.authorize).not.toHaveBeenCalled();
281
+ });
282
+ test("answers the operator's refusal without running the script", async () => {
283
+ authorizeRun.mockResolvedValue({ ok: false, status: 402, error: "Insufficient balance" });
284
+ const response = await runWith(await runToken("org_caller"));
285
+ expect(response.statusCode).toBe(402);
286
+ expect(response.json()).toEqual({ error: "Insufficient balance" });
287
+ expect(tokenService.operatorClient.authorize).not.toHaveBeenCalled();
288
+ });
289
+ test("charges a script that fails for what it executed, and releases a run the node failed", async () => {
290
+ const failing = await runWith(await runToken("org_caller"), "request R {\n return 1 / 0\n}");
291
+ expect(failing.statusCode).toBe(422);
292
+ await vi.waitFor(() => expect(captureRun).toHaveBeenCalledWith({ runId: expect.any(String), tokens: 4 }));
293
+ vi.spyOn(tokenService.resourceResolver, "resolve").mockRejectedValue(new Error("database is gone"));
294
+ const broken = await runWith(await runToken("org_caller"));
295
+ expect(broken.statusCode).toBe(500);
296
+ await vi.waitFor(() => expect(releaseRun).toHaveBeenCalledTimes(1));
297
+ });
298
+ test.each([
299
+ ["addressed to another node", () => runToken("org_caller", { audience: generateBase64KeyPair().publicKey })],
300
+ ["signed by another key", () => runToken("org_caller", { key: generateKeyPairSync("ed25519").privateKey })],
301
+ ["expired", () => runToken("org_caller", { expiresIn: "-1m" })],
302
+ ])("refuses a token %s", async (_, token) => {
303
+ const response = await runWith(await token());
304
+ expect(response.statusCode).toBe(401);
305
+ expect(response.json()).toEqual({ error: "Invalid run token" });
306
+ expect(authorizeRun).not.toHaveBeenCalled();
307
+ });
308
+ test("gives the script the run token's claims as the built-in jwt", async () => {
309
+ const response = await runWith(await runToken("org_caller"), "request R {\n return jwt\n}");
310
+ expect(response.statusCode).toBe(200);
311
+ expect(response.json().returnValue).toEqual({
312
+ iss: "stellarium", sub: "cli_test", aud: config.publicKey, org: "org_caller", iat: expect.any(Number), exp: expect.any(Number),
313
+ });
314
+ });
315
+ test.each([
316
+ ["redeclares", "var jwt = 1", /'jwt' is a built-in variable/],
317
+ ["changes", 'jwt.sub = "cli_other"', /read-only/],
318
+ ])("fails a script that %s jwt", async (_, statement, details) => {
319
+ const response = await runWith(await runToken(ORGANIZATION), `request R {\n ${statement}\n}`);
320
+ expect(response.statusCode).toBe(422);
321
+ expect(response.json()).toMatchObject({ error: "Script failed", details: expect.stringMatching(details) });
322
+ });
323
+ test("gives an API-key run a null jwt", async () => {
324
+ const response = await tokenApp.inject({ method: "POST", url: "/v1/run", headers: { authorization: "Bearer secret" }, payload: { source: "request R {\n return jwt\n}" } });
325
+ expect(response.statusCode).toBe(200);
326
+ expect(response.json().returnValue).toBeNull();
327
+ });
328
+ test("still accepts the API key", async () => {
329
+ const response = await tokenApp.inject({ method: "POST", url: "/v1/run", headers: { authorization: "Bearer secret" }, payload: { source: temperatureSource } });
330
+ expect(response.statusCode).toBe(200);
331
+ expect(authorizeRun).not.toHaveBeenCalled();
332
+ });
333
+ });
222
334
  });
223
335
  describe("GET /v1/self", () => {
224
336
  test("describes this node from its config", async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stellarium-ts",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "A Stellarium Node in TypeScript.",
5
5
  "homepage": "https://github.com/qualletio/stellarium-ts#readme",
6
6
  "bugs": {
@@ -45,7 +45,8 @@
45
45
  "@fastify/rate-limit": "^11.2.1",
46
46
  "dotenv": "^18.0.5",
47
47
  "fastify": "^5.12.5",
48
- "requestscript": "1.1.0",
48
+ "jose": "6.1.0",
49
+ "requestscript": "1.3.0",
49
50
  "sqlite": "^5.1.1",
50
51
  "sqlite3": "^6.0.1"
51
52
  },