stellarium-ts 0.16.0 → 0.18.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 (46) hide show
  1. package/dist/client/operatorAuth.d.ts +19 -0
  2. package/dist/client/operatorAuth.d.ts.map +1 -0
  3. package/dist/client/operatorAuth.js +21 -0
  4. package/dist/client/operatorClient.d.ts +103 -31
  5. package/dist/client/operatorClient.d.ts.map +1 -1
  6. package/dist/client/operatorClient.js +107 -23
  7. package/dist/client/runTokens.d.ts +23 -0
  8. package/dist/client/runTokens.d.ts.map +1 -0
  9. package/dist/client/runTokens.js +34 -0
  10. package/dist/db/db.d.ts.map +1 -1
  11. package/dist/db/db.js +8 -5
  12. package/dist/index.d.ts +4 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +4 -0
  15. package/dist/resource/inference.d.ts +5 -3
  16. package/dist/resource/inference.d.ts.map +1 -1
  17. package/dist/resource/inference.js +17 -7
  18. package/dist/resource/invoker.d.ts +48 -14
  19. package/dist/resource/invoker.d.ts.map +1 -1
  20. package/dist/resource/invoker.js +108 -38
  21. package/dist/resource/repository.d.ts +13 -7
  22. package/dist/resource/repository.d.ts.map +1 -1
  23. package/dist/resource/repository.js +23 -13
  24. package/dist/resource/settlement.d.ts +25 -0
  25. package/dist/resource/settlement.d.ts.map +1 -0
  26. package/dist/resource/settlement.js +59 -0
  27. package/dist/routes.d.ts +16 -0
  28. package/dist/routes.d.ts.map +1 -1
  29. package/dist/routes.js +103 -76
  30. package/dist/server.d.ts +11 -6
  31. package/dist/server.d.ts.map +1 -1
  32. package/dist/server.js +32 -7
  33. package/dist/service.d.ts +18 -6
  34. package/dist/service.d.ts.map +1 -1
  35. package/dist/service.js +8 -3
  36. package/dist/tests/client/operatorClient.test.js +94 -36
  37. package/dist/tests/db/db.test.js +4 -4
  38. package/dist/tests/helpers.d.ts +4 -3
  39. package/dist/tests/helpers.d.ts.map +1 -1
  40. package/dist/tests/helpers.js +8 -6
  41. package/dist/tests/integration/network.test.js +69 -18
  42. package/dist/tests/resource/invoker.test.js +33 -9
  43. package/dist/tests/resource/repository.test.js +9 -9
  44. package/dist/tests/routes.test.js +165 -40
  45. package/dist/tests/server.test.js +15 -15
  46. package/package.json +3 -2
@@ -3,7 +3,7 @@ import { signData } from "../../crypto/utils.js";
3
3
  import { openDatabase } from "../../db/db.js";
4
4
  import { PeerRepository } from "../../peer/repository.js";
5
5
  import { resourceAnnouncementPayload, ResourceRepository } from "../../resource/repository.js";
6
- import { generateBase64KeyPair, RECIPIENT, weatherResource } from "../helpers.js";
6
+ import { generateBase64KeyPair, ORGANIZATION, weatherResource } from "../helpers.js";
7
7
  const SELF = "http://self.example";
8
8
  const PROVIDER = "http://provider.example";
9
9
  function resource(name, functions, baseUrl = PROVIDER) {
@@ -52,16 +52,16 @@ describe("ResourceRepository", () => {
52
52
  expect((await repository.getResourceFunction("com.provider", "Forecast", "tomorrow"))?.description).toBe("Tomorrow's forecast");
53
53
  });
54
54
  test("keeps inference functions' kind and per-token pricing", async () => {
55
- const pricing = { inputCostMicroUsdcPerMillionTokens: 3_000_000, outputCostMicroUsdcPerMillionTokens: 0, recipientAddress: RECIPIENT };
55
+ const pricing = { inputCostMicroUsdPerMillionTokens: 3_000_000, outputCostMicroUsdPerMillionTokens: 0, recipientOrganizationId: ORGANIZATION };
56
56
  await repository.learn(resource("Model", [
57
57
  { name: "complete", kind: "inference", parameters: [{ name: "maxTokens", type: "int32" }], paymentManifest: pricing },
58
- { name: "count", paymentManifest: { costMicroUsdc: 5, recipientAddress: RECIPIENT } },
58
+ { name: "count", paymentManifest: { costMicroUsd: 5, recipientOrganizationId: ORGANIZATION } },
59
59
  ]));
60
60
  const complete = await repository.getResourceFunction("com.provider", "Model", "complete");
61
61
  expect(complete).toMatchObject({ kind: "inference", paymentManifest: pricing });
62
62
  const count = await repository.getResourceFunction("com.provider", "Model", "count");
63
63
  expect(count?.kind).toBeUndefined();
64
- expect(count?.paymentManifest).toEqual({ id: expect.any(Number), costMicroUsdc: 5, recipientAddress: RECIPIENT });
64
+ expect(count?.paymentManifest).toEqual({ id: expect.any(Number), costMicroUsd: 5, recipientOrganizationId: ORGANIZATION });
65
65
  const functions = (await repository.getResource("com.provider.Model"))?.functions;
66
66
  expect(functions.find(fn => fn.name === "complete")).toMatchObject({ kind: "inference", paymentManifest: pricing });
67
67
  });
@@ -102,12 +102,12 @@ describe("ResourceRepository", () => {
102
102
  test("returns each function's own payment manifest", async () => {
103
103
  await repository.learn(resource("Forecast", [
104
104
  { name: "free" },
105
- { name: "paid", paymentManifest: { costMicroUsdc: 250, recipientAddress: RECIPIENT } },
105
+ { name: "paid", paymentManifest: { costMicroUsd: 250, recipientOrganizationId: ORGANIZATION } },
106
106
  ]));
107
107
  const found = await repository.getResource("com.provider.Forecast");
108
108
  expect(found.metadata).toEqual({ baseUrl: PROVIDER });
109
109
  expect(found.functions.find(f => f.name === "free")?.paymentManifest).toBeUndefined();
110
- expect(found.functions.find(f => f.name === "paid")?.paymentManifest).toMatchObject({ costMicroUsdc: 250, recipientAddress: RECIPIENT });
110
+ expect(found.functions.find(f => f.name === "paid")?.paymentManifest).toMatchObject({ costMicroUsd: 250, recipientOrganizationId: ORGANIZATION });
111
111
  });
112
112
  test("prefers the internal resource", async () => {
113
113
  const found = await repository.getResource("com.example.Weather");
@@ -133,13 +133,13 @@ describe("ResourceRepository", () => {
133
133
  test("adds new functions, parameters and payment manifests to a known resource", async () => {
134
134
  await save([resource("Forecast", [{ name: "today" }])]);
135
135
  const result = await save([resource("Forecast", [
136
- { name: "today", parameters: [{ name: "city", type: "string" }], paymentManifest: { costMicroUsdc: 100, recipientAddress: RECIPIENT } },
136
+ { name: "today", parameters: [{ name: "city", type: "string" }], paymentManifest: { costMicroUsd: 100, recipientOrganizationId: ORGANIZATION } },
137
137
  { name: "tomorrow" },
138
138
  ])]);
139
139
  expect(result).toEqual({ ok: true, rejected: [] });
140
140
  const today = await repository.getResourceFunction("com.provider", "Forecast", "today");
141
141
  expect(today?.parameters).toEqual([{ name: "city", type: "string" }]);
142
- expect(today?.paymentManifest).toMatchObject({ costMicroUsdc: 100 });
142
+ expect(today?.paymentManifest).toMatchObject({ costMicroUsd: 100 });
143
143
  expect(await repository.getResourceFunction("com.provider", "Forecast", "tomorrow")).toBeDefined();
144
144
  });
145
145
  test("drops descriptions that aren't strings and websites that aren't http(s) URLs", async () => {
@@ -156,7 +156,7 @@ describe("ResourceRepository", () => {
156
156
  expect([radar.website, radar.description]).toEqual(["https://radar.example", "Rain radar"]);
157
157
  });
158
158
  test("removes functions and payment manifests the peer no longer advertises", async () => {
159
- await save([resource("Forecast", [{ name: "today", paymentManifest: { costMicroUsdc: 100, recipientAddress: RECIPIENT } }, { name: "old" }])]);
159
+ await save([resource("Forecast", [{ name: "today", paymentManifest: { costMicroUsd: 100, recipientOrganizationId: ORGANIZATION } }, { name: "old" }])]);
160
160
  await save([resource("Forecast", [{ name: "today" }])]);
161
161
  expect((await repository.getResourceFunction("com.provider", "Forecast", "today"))?.paymentManifest).toBeUndefined();
162
162
  expect(await repository.getResourceFunction("com.provider", "Forecast", "old")).toBeUndefined();
@@ -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";
@@ -10,7 +12,7 @@ import { CombinationResourceInvoker, inferenceVoucherPayload, paymentVoucherPayl
10
12
  import { resourceAnnouncementPayload } from "../resource/repository.js";
11
13
  import routes from "../routes.js";
12
14
  import { createService } from "../service.js";
13
- import { COST, generateBase64KeyPair, INFERENCE_RATES, modelResource, RECIPIENT, temperatureSource, testConfig, weatherResource } from "./helpers.js";
15
+ import { COST, generateBase64KeyPair, INFERENCE_RATES, modelResource, ORGANIZATION, temperatureSource, testConfig, weatherResource } from "./helpers.js";
14
16
  const parameters = [{ name: "city", value: "Oslo" }];
15
17
  const inMinutes = (minutes) => new Date(Date.now() + minutes * 60_000).toISOString();
16
18
  describe("routes", () => {
@@ -22,12 +24,16 @@ describe("routes", () => {
22
24
  let config;
23
25
  let service;
24
26
  let fastify;
25
- let settlePayment;
27
+ let authorize;
28
+ let capture;
29
+ let release;
26
30
  beforeEach(async () => {
27
31
  db = await openDatabase(":memory:");
28
32
  config = testConfig();
29
33
  service = createService(db, [weather], config);
30
- settlePayment = vi.spyOn(service.operatorClient, "settlePayment").mockResolvedValue(undefined);
34
+ authorize = vi.spyOn(service.operatorClient, "authorize").mockResolvedValue({ ok: true });
35
+ capture = vi.spyOn(service.operatorClient, "capture").mockResolvedValue(undefined);
36
+ release = vi.spyOn(service.operatorClient, "release").mockResolvedValue(undefined);
31
37
  fastify = Fastify();
32
38
  await fastify.register(routes, { prefix: "/v1", service });
33
39
  await fastify.ready();
@@ -41,7 +47,7 @@ describe("routes", () => {
41
47
  const flushSettlement = () => new Promise((resolve) => setTimeout(resolve, 10));
42
48
  describe("POST /v1/run/resource", () => {
43
49
  function voucherFor(functionName = "temperature", overrides = {}) {
44
- const voucher = callerInvoker.createPaymentVoucher(RECIPIENT, COST, weather.path, weather.name, functionName, parameters);
50
+ const voucher = callerInvoker.createPaymentVoucher(ORGANIZATION, COST, weather.path, weather.name, functionName, parameters);
45
51
  if (Object.keys(overrides).length === 0) {
46
52
  return voucher;
47
53
  }
@@ -56,13 +62,30 @@ describe("routes", () => {
56
62
  payload: { resourcePath: weather.path, resourceName: weather.name, functionName: "temperature", parameters, ...body },
57
63
  });
58
64
  }
59
- test("runs a paid function with a valid voucher and settles it", async () => {
65
+ test("authorizes a paid function's voucher, runs it, then captures it", async () => {
60
66
  const paymentVoucher = voucherFor();
61
67
  const response = await runResource({ paymentVoucher });
62
68
  expect(response.statusCode).toBe(200);
63
69
  expect(response.json()).toEqual({ value: 12, error: null });
70
+ expect(authorize).toHaveBeenCalledExactlyOnceWith(paymentVoucher);
64
71
  await flushSettlement();
65
- expect(settlePayment).toHaveBeenCalledExactlyOnceWith({ voucher: paymentVoucher });
72
+ expect(capture).toHaveBeenCalledExactlyOnceWith({ voucher: paymentVoucher });
73
+ expect(release).not.toHaveBeenCalled();
74
+ });
75
+ test.each([
76
+ [402, "Insufficient balance"],
77
+ [403, "Payer organization is frozen"],
78
+ [503, "Payment operator is unavailable"],
79
+ ])("answers the operator's %i refusal without running the function", async (status, error) => {
80
+ authorize.mockResolvedValueOnce({ ok: false, status, error });
81
+ const exec = vi.spyOn(weather.functions[0], "exec");
82
+ const response = await runResource({ paymentVoucher: voucherFor() });
83
+ expect(response.statusCode).toBe(status);
84
+ expect(response.json()).toEqual({ error });
85
+ expect(exec).not.toHaveBeenCalled();
86
+ await flushSettlement();
87
+ expect(capture).not.toHaveBeenCalled();
88
+ expect(release).not.toHaveBeenCalled();
66
89
  });
67
90
  test("refuses a voucher from a blocked caller, even for an unrestricted function", async () => {
68
91
  await service.grantService.block(caller.publicKey);
@@ -70,7 +93,7 @@ describe("routes", () => {
70
93
  expect(response.statusCode).toBe(403);
71
94
  expect(response.json()).toEqual({ error: "Caller is blocked" });
72
95
  await flushSettlement();
73
- expect(settlePayment).not.toHaveBeenCalled();
96
+ expect(capture).not.toHaveBeenCalled();
74
97
  });
75
98
  test("accepts a voucher that pays more than the cost", async () => {
76
99
  const response = await runResource({ paymentVoucher: voucherFor("temperature", { amount: COST + 1 }) });
@@ -80,11 +103,11 @@ describe("routes", () => {
80
103
  const response = await runResource({});
81
104
  expect(response.statusCode).toBe(402);
82
105
  await flushSettlement();
83
- expect(settlePayment).not.toHaveBeenCalled();
106
+ expect(capture).not.toHaveBeenCalled();
84
107
  });
85
108
  test.each([
86
109
  ["pays less than the cost", { amount: COST - 1 }],
87
- ["pays a different recipient", { recipientAddress: "11111111111111111111111111111111" }],
110
+ ["pays a different recipient", { recipientOrganizationId: "org_other" }],
88
111
  ["has expired", { expiresAt: new Date(Date.now() - 1000).toISOString() }],
89
112
  ["has an unparseable expiry", { expiresAt: "not-a-date" }],
90
113
  ])("rejects a voucher that %s", async (_, overrides) => {
@@ -92,14 +115,14 @@ describe("routes", () => {
92
115
  expect(response.statusCode).toBe(400);
93
116
  expect(response.json()).toEqual({ error: "Invalid payment voucher" });
94
117
  await flushSettlement();
95
- expect(settlePayment).not.toHaveBeenCalled();
118
+ expect(capture).not.toHaveBeenCalled();
96
119
  });
97
120
  test("rejects a voucher whose parameters were tampered with", async () => {
98
121
  const response = await runResource({ paymentVoucher: voucherFor(), parameters: [{ name: "city", value: "Bergen" }] });
99
122
  expect(response.statusCode).toBe(400);
100
123
  });
101
124
  test("rejects a voucher issued for a different function", async () => {
102
- const paymentVoucher = callerInvoker.createPaymentVoucher(RECIPIENT, COST, weather.path, weather.name, "humidity", parameters);
125
+ const paymentVoucher = callerInvoker.createPaymentVoucher(ORGANIZATION, COST, weather.path, weather.name, "humidity", parameters);
103
126
  const response = await runResource({ paymentVoucher });
104
127
  expect(response.statusCode).toBe(400);
105
128
  });
@@ -122,21 +145,23 @@ describe("routes", () => {
122
145
  expect(response.statusCode).toBe(200);
123
146
  expect(response.json()).toEqual({ value: 80, error: null });
124
147
  await flushSettlement();
125
- expect(settlePayment).not.toHaveBeenCalled();
148
+ expect(capture).not.toHaveBeenCalled();
126
149
  });
127
150
  test("rejects an unknown function without settling the voucher", async () => {
128
151
  const response = await runResource({ functionName: "missing", paymentVoucher: voucherFor("missing") });
129
152
  expect(response.statusCode).toBe(400);
130
153
  expect(response.json()).toEqual({ error: "Function not found" });
131
154
  await flushSettlement();
132
- expect(settlePayment).not.toHaveBeenCalled();
155
+ expect(capture).not.toHaveBeenCalled();
133
156
  });
134
- test("does not settle when the invoke returns an error", async () => {
157
+ test("releases the hold when the invoke returns an error", async () => {
135
158
  vi.spyOn(service.resourceInvoker, "invoke").mockResolvedValueOnce({ value: null, error: { message: "boom" } });
136
- const response = await runResource({ paymentVoucher: voucherFor() });
159
+ const paymentVoucher = voucherFor();
160
+ const response = await runResource({ paymentVoucher });
137
161
  expect(response.statusCode).toBe(200);
138
162
  await flushSettlement();
139
- expect(settlePayment).not.toHaveBeenCalled();
163
+ expect(capture).not.toHaveBeenCalled();
164
+ expect(release).toHaveBeenCalledExactlyOnceWith(paymentVoucher);
140
165
  });
141
166
  test("rejects an unknown resource", async () => {
142
167
  const response = await runResource({ resourceName: "Missing" });
@@ -161,7 +186,7 @@ describe("routes", () => {
161
186
  const response = await run("Bearer secret");
162
187
  expect(response.statusCode).toBe(200);
163
188
  // RequestScript resource calls evaluate to the invoker's { value, error } result.
164
- expect(response.json()).toEqual({ returnValue: { value: 12, error: null } });
189
+ expect(response.json()).toEqual({ usage: { tokens: expect.any(Number) }, returnValue: { value: 12, error: null } });
165
190
  });
166
191
  test("requires an Authorization header", async () => {
167
192
  const response = await run();
@@ -196,6 +221,96 @@ describe("routes", () => {
196
221
  expect(response.statusCode).toBe(400);
197
222
  expect(response.json()).toMatchObject({ error: "Invalid script" });
198
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("still accepts the API key", async () => {
309
+ const response = await tokenApp.inject({ method: "POST", url: "/v1/run", headers: { authorization: "Bearer secret" }, payload: { source: temperatureSource } });
310
+ expect(response.statusCode).toBe(200);
311
+ expect(authorizeRun).not.toHaveBeenCalled();
312
+ });
313
+ });
199
314
  });
200
315
  describe("GET /v1/self", () => {
201
316
  test("describes this node from its config", async () => {
@@ -270,7 +385,7 @@ describe("routes", () => {
270
385
  parameters: [{ name: "city", type: "string" }],
271
386
  returnType: "string",
272
387
  exec: async () => "sunny",
273
- paymentManifest: { costMicroUsdc: 500, recipientAddress: RECIPIENT },
388
+ paymentManifest: { costMicroUsd: 500, recipientOrganizationId: ORGANIZATION },
274
389
  }],
275
390
  });
276
391
  function broadcast(resources, privateKey = keys.privateKey, publicKey = keys.publicKey) {
@@ -294,7 +409,7 @@ describe("routes", () => {
294
409
  const resources = (await fastify.inject({ method: "GET", url: "/v1/resources" })).json().resources;
295
410
  expect(resources.find((r) => r.name === "Forecast")).toMatchObject({
296
411
  baseUrl: peer.baseUrl,
297
- functions: [{ name: "tomorrow", paymentManifest: { costMicroUsdc: 500, recipientAddress: RECIPIENT } }],
412
+ functions: [{ name: "tomorrow", paymentManifest: { costMicroUsd: 500, recipientOrganizationId: ORGANIZATION } }],
298
413
  });
299
414
  });
300
415
  test("rejects a broadcast from an unknown peer", async () => {
@@ -366,7 +481,7 @@ describe("restricted functions", () => {
366
481
  name: "Vault",
367
482
  functions: [
368
483
  { 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: { costMicroUsdc: COST, recipientAddress: RECIPIENT } },
484
+ { name: "premium", parameters: [{ name: "city", type: "string" }], returnType: "int32", exec: async () => 9, restricted: true, paymentManifest: { costMicroUsd: COST, recipientOrganizationId: ORGANIZATION } },
370
485
  ],
371
486
  };
372
487
  const call = (functionName) => ({ resourcePath: "com.example", resourceName: "Vault", functionName, parameters });
@@ -379,7 +494,9 @@ describe("restricted functions", () => {
379
494
  beforeEach(async () => {
380
495
  db = await openDatabase(":memory:");
381
496
  service = createService(db, [restricted], testConfig());
382
- vi.spyOn(service.operatorClient, "settlePayment").mockResolvedValue(undefined);
497
+ vi.spyOn(service.operatorClient, "authorize").mockResolvedValue({ ok: true });
498
+ vi.spyOn(service.operatorClient, "capture").mockResolvedValue(undefined);
499
+ vi.spyOn(service.operatorClient, "release").mockResolvedValue(undefined);
383
500
  callerDb = await openDatabase(":memory:");
384
501
  callerGrants = new GrantService(new GrantRepository(callerDb), { baseUrl: "http://caller", ...caller });
385
502
  grant = await service.grantService.issue(caller.publicKey, [{ resourcePath: "com.example", resourceName: "Vault" }], { lifetimeMs: 3_600_000 });
@@ -491,11 +608,11 @@ describe("restricted functions", () => {
491
608
  expect(response.statusCode).toBe(403);
492
609
  });
493
610
  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);
611
+ const paymentVoucher = callerInvoker.createPaymentVoucher(ORGANIZATION, COST, "com.example", "Vault", "premium", parameters);
495
612
  const unauthorized = await runResource({ ...call("premium"), paymentVoucher });
496
613
  expect(unauthorized.statusCode).toBe(403);
497
614
  await new Promise((resolve) => setTimeout(resolve, 10));
498
- expect(service.operatorClient.settlePayment).not.toHaveBeenCalled();
615
+ expect(service.operatorClient.authorize).not.toHaveBeenCalled();
499
616
  const unpaid = await runResource({ ...call("premium"), authorization: callerGrants.authorize(grant, call("premium")) });
500
617
  expect(unpaid.statusCode).toBe(402);
501
618
  const paid = await runResource({ ...call("premium"), paymentVoucher, authorization: callerGrants.authorize(grant, call("premium")) });
@@ -571,15 +688,19 @@ describe("inference functions", () => {
571
688
  // "Hello world!" is 3 input tokens by the fixture's estimator. 3 at $3/M plus 1,000 at $15/M, rounded up.
572
689
  const parameters = [{ name: "prompt", value: "Hello world!" }, { name: "maxTokens", value: 1000 }];
573
690
  const MAX_AMOUNT = 15_009;
574
- const manifest = { ...INFERENCE_RATES, recipientAddress: RECIPIENT };
691
+ const manifest = { ...INFERENCE_RATES, recipientOrganizationId: ORGANIZATION };
575
692
  let db;
576
693
  let service;
577
694
  let fastify;
578
- let settlePayment;
695
+ let authorize;
696
+ let capture;
697
+ let release;
579
698
  beforeEach(async () => {
580
699
  db = await openDatabase(":memory:");
581
700
  service = createService(db, [weather, model], testConfig());
582
- settlePayment = vi.spyOn(service.operatorClient, "settlePayment").mockResolvedValue(undefined);
701
+ authorize = vi.spyOn(service.operatorClient, "authorize").mockResolvedValue({ ok: true });
702
+ capture = vi.spyOn(service.operatorClient, "capture").mockResolvedValue(undefined);
703
+ release = vi.spyOn(service.operatorClient, "release").mockResolvedValue(undefined);
583
704
  fastify = Fastify();
584
705
  await fastify.register(routes, { prefix: "/v1", service });
585
706
  await fastify.ready();
@@ -639,7 +760,7 @@ describe("inference functions", () => {
639
760
  expect(response.statusCode).toBe(200);
640
761
  expect(response.json()).toEqual({ value: "echo: Hello world!", error: null, usage: { inputTokens: 3, outputTokens: 1000 } });
641
762
  await flushSettlement();
642
- expect(settlePayment).toHaveBeenCalledExactlyOnceWith({ voucher: paymentVoucher, usage: { inputTokens: 3, outputTokens: 1000 } });
763
+ expect(capture).toHaveBeenCalledExactlyOnceWith({ voucher: paymentVoucher, usage: { inputTokens: 3, outputTokens: 1000 } });
643
764
  });
644
765
  test("refuses a voucher below the estimator's requirement without running inference", async () => {
645
766
  const exec = vi.spyOn(model.functions[0], "exec");
@@ -648,20 +769,20 @@ describe("inference functions", () => {
648
769
  expect(response.json()).toMatchObject({ requiredMaxAmount: MAX_AMOUNT });
649
770
  expect(exec).not.toHaveBeenCalled();
650
771
  await flushSettlement();
651
- expect(settlePayment).not.toHaveBeenCalled();
772
+ expect(capture).not.toHaveBeenCalled();
652
773
  });
653
774
  test.each([
654
- ["a different input rate", { inputCostMicroUsdcPerMillionTokens: 1 }],
655
- ["a different output rate", { outputCostMicroUsdcPerMillionTokens: 1 }],
775
+ ["a different input rate", { inputCostMicroUsdPerMillionTokens: 1 }],
776
+ ["a different output rate", { outputCostMicroUsdPerMillionTokens: 1 }],
656
777
  ["a maxTokens other than the call's", { maxTokens: 999 }],
657
- ["a different recipient", { recipientAddress: "11111111111111111111111111111111" }],
778
+ ["a different recipient", { recipientOrganizationId: "org_other" }],
658
779
  ])("rejects a voucher with %s", async (_, overrides) => {
659
780
  const response = await call("/v1/run/resource", { paymentVoucher: voucherFor("complete", overrides) });
660
781
  expect(response.statusCode).toBe(400);
661
782
  expect(response.json()).toEqual({ error: "Invalid payment voucher" });
662
783
  });
663
784
  test("rejects a fixed voucher for an inference function, and an inference voucher for a fixed function", async () => {
664
- const fixed = callerInvoker.createPaymentVoucher(RECIPIENT, MAX_AMOUNT, model.path, model.name, "complete", parameters);
785
+ const fixed = callerInvoker.createPaymentVoucher(ORGANIZATION, MAX_AMOUNT, model.path, model.name, "complete", parameters);
665
786
  expect((await call("/v1/run/resource", { paymentVoucher: fixed })).statusCode).toBe(400);
666
787
  const weatherParameters = [{ name: "city", value: "Oslo" }];
667
788
  const inference = callerInvoker.createInferenceVoucher(manifest, MAX_AMOUNT, 1000, weather.path, weather.name, "temperature", weatherParameters);
@@ -674,21 +795,25 @@ describe("inference functions", () => {
674
795
  const response = await call("/v1/run/resource", { paymentVoucher });
675
796
  expect(response.json().usage).toEqual({ inputTokens: 3, outputTokens: 1000 });
676
797
  await flushSettlement();
677
- expect(settlePayment).toHaveBeenCalledExactlyOnceWith({ voucher: paymentVoucher, usage: { inputTokens: 3, outputTokens: 1000 } });
798
+ expect(capture).toHaveBeenCalledExactlyOnceWith({ voucher: paymentVoucher, usage: { inputTokens: 3, outputTokens: 1000 } });
678
799
  });
679
- test("does not settle a call that charges nothing", async () => {
800
+ test("releases the hold of a call that charges nothing", async () => {
680
801
  vi.spyOn(model.functions[0], "exec").mockResolvedValue({ value: "", usage: { inputTokens: 0, outputTokens: 0 } });
681
- const response = await call("/v1/run/resource", { paymentVoucher: voucherFor() });
802
+ const paymentVoucher = voucherFor();
803
+ const response = await call("/v1/run/resource", { paymentVoucher });
682
804
  expect(response.statusCode).toBe(200);
683
805
  await flushSettlement();
684
- expect(settlePayment).not.toHaveBeenCalled();
806
+ expect(capture).not.toHaveBeenCalled();
807
+ expect(release).toHaveBeenCalledExactlyOnceWith(paymentVoucher);
685
808
  });
686
- test("does not settle when the function doesn't report usage", async () => {
809
+ test("releases the hold when the function doesn't report usage", async () => {
687
810
  vi.spyOn(model.functions[0], "exec").mockResolvedValue("no usage");
688
- const response = await call("/v1/run/resource", { paymentVoucher: voucherFor() });
811
+ const paymentVoucher = voucherFor();
812
+ const response = await call("/v1/run/resource", { paymentVoucher });
689
813
  expect(response.json().error.message).toContain("must return { value, usage");
690
814
  await flushSettlement();
691
- expect(settlePayment).not.toHaveBeenCalled();
815
+ expect(capture).not.toHaveBeenCalled();
816
+ expect(release).toHaveBeenCalledExactlyOnceWith(paymentVoucher);
692
817
  });
693
818
  });
694
819
  test("GET /v1/resources announces the kind and per-token pricing", async () => {
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, test, vi } from "vitest";
2
- import { DEFAULT_COST_LIMIT_MICRO_USDC, resolveConfig } from "../server.js";
2
+ import { DEFAULT_COST_LIMIT_MICRO_USD, resolveConfig } from "../server.js";
3
3
  import { modelResource, weatherResource } from "./helpers.js";
4
4
  describe("resolveConfig", () => {
5
5
  const keys = { baseUrl: "http://node.example", publicKey: "public", privateKey: "private" };
@@ -15,15 +15,15 @@ describe("resolveConfig", () => {
15
15
  vi.stubEnv("BASE_URL", "http://env.example");
16
16
  vi.stubEnv("SIGNING_PUBLIC_KEY", "env-public");
17
17
  vi.stubEnv("SIGNING_PRIVATE_KEY", "env-private");
18
- vi.stubEnv("COST_LIMIT_MICRO_USDC", "500");
19
- expect(resolveConfig({})).toMatchObject({ baseUrl: "http://env.example", publicKey: "env-public", privateKey: "env-private", costLimitMicroUsdc: 500 });
18
+ vi.stubEnv("COST_LIMIT_MICRO_USD", "500");
19
+ expect(resolveConfig({})).toMatchObject({ baseUrl: "http://env.example", publicKey: "env-public", privateKey: "env-private", costLimitMicroUsd: 500 });
20
20
  });
21
21
  test("defaults the cost limit", () => {
22
- vi.stubEnv("COST_LIMIT_MICRO_USDC", undefined);
23
- expect(resolveConfig(keys).costLimitMicroUsdc).toBe(DEFAULT_COST_LIMIT_MICRO_USDC);
22
+ vi.stubEnv("COST_LIMIT_MICRO_USD", undefined);
23
+ expect(resolveConfig(keys).costLimitMicroUsd).toBe(DEFAULT_COST_LIMIT_MICRO_USD);
24
24
  });
25
- test.each([-1, 1.5, Number.NaN])("rejects a cost limit of %s", (costLimitMicroUsdc) => {
26
- expect(() => resolveConfig({ ...keys, costLimitMicroUsdc })).toThrow("costLimitMicroUsdc");
25
+ test.each([-1, 1.5, Number.NaN])("rejects a cost limit of %s", (costLimitMicroUsd) => {
26
+ expect(() => resolveConfig({ ...keys, costLimitMicroUsd })).toThrow("costLimitMicroUsd");
27
27
  });
28
28
  test("requires the signing keys and base URL", () => {
29
29
  vi.stubEnv("SIGNING_PRIVATE_KEY", undefined);
@@ -35,12 +35,12 @@ describe("resolveConfig", () => {
35
35
  expect(() => resolveConfig(keys, [weatherResource()])).toThrow("OPERATOR_URL is required");
36
36
  });
37
37
  test("defaults the inference cost limit to the cost limit, and reads its own option or environment variable", () => {
38
- vi.stubEnv("INFERENCE_COST_LIMIT_MICRO_USDC", undefined);
39
- expect(resolveConfig({ ...keys, costLimitMicroUsdc: 700 }).inferenceCostLimitMicroUsdc).toBe(700);
40
- vi.stubEnv("INFERENCE_COST_LIMIT_MICRO_USDC", "900");
41
- expect(resolveConfig({ ...keys, costLimitMicroUsdc: 700 }).inferenceCostLimitMicroUsdc).toBe(900);
42
- expect(resolveConfig({ ...keys, inferenceCostLimitMicroUsdc: 1_000 }).inferenceCostLimitMicroUsdc).toBe(1_000);
43
- expect(() => resolveConfig({ ...keys, inferenceCostLimitMicroUsdc: -1 })).toThrow("inferenceCostLimitMicroUsdc");
38
+ vi.stubEnv("INFERENCE_COST_LIMIT_MICRO_USD", undefined);
39
+ expect(resolveConfig({ ...keys, costLimitMicroUsd: 700 }).inferenceCostLimitMicroUsd).toBe(700);
40
+ vi.stubEnv("INFERENCE_COST_LIMIT_MICRO_USD", "900");
41
+ expect(resolveConfig({ ...keys, costLimitMicroUsd: 700 }).inferenceCostLimitMicroUsd).toBe(900);
42
+ expect(resolveConfig({ ...keys, inferenceCostLimitMicroUsd: 1_000 }).inferenceCostLimitMicroUsd).toBe(1_000);
43
+ expect(() => resolveConfig({ ...keys, inferenceCostLimitMicroUsd: -1 })).toThrow("inferenceCostLimitMicroUsd");
44
44
  });
45
45
  test("accepts well-declared inference functions", () => {
46
46
  expect(() => resolveConfig({ ...keys, operatorUrl: "http://operator.example" }, [modelResource()])).not.toThrow();
@@ -49,8 +49,8 @@ describe("resolveConfig", () => {
49
49
  ["without a maxTokens parameter", { parameters: [{ name: "prompt", type: "string" }] }, "maxTokens: int32"],
50
50
  ["whose maxTokens isn't an int32", { parameters: [{ name: "maxTokens", type: "int64" }] }, "maxTokens: int32"],
51
51
  ["that is paid but has no estimator", { estimateInputTokens: undefined }, "estimateInputTokens"],
52
- ["priced with a flat cost", { paymentManifest: { costMicroUsdc: 5, recipientAddress: "r" } }, "per million tokens"],
53
- ["with a negative rate", { paymentManifest: { inputCostMicroUsdcPerMillionTokens: -1, outputCostMicroUsdcPerMillionTokens: 0, recipientAddress: "r" } }, "per million tokens"],
52
+ ["priced with a flat cost", { paymentManifest: { costMicroUsd: 5, recipientOrganizationId: "r" } }, "per million tokens"],
53
+ ["with a negative rate", { paymentManifest: { inputCostMicroUsdPerMillionTokens: -1, outputCostMicroUsdPerMillionTokens: 0, recipientOrganizationId: "r" } }, "per million tokens"],
54
54
  ])("rejects an inference function %s", (_, overrides, message) => {
55
55
  const model = modelResource();
56
56
  model.functions = [{ ...model.functions[0], ...overrides }];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stellarium-ts",
3
- "version": "0.16.0",
3
+ "version": "0.18.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.2.0",
49
50
  "sqlite": "^5.1.1",
50
51
  "sqlite3": "^6.0.1"
51
52
  },