stellarium-ts 0.16.0 → 0.17.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 (39) 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 +68 -31
  5. package/dist/client/operatorClient.d.ts.map +1 -1
  6. package/dist/client/operatorClient.js +82 -23
  7. package/dist/db/db.d.ts.map +1 -1
  8. package/dist/db/db.js +8 -5
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -0
  12. package/dist/resource/inference.d.ts +3 -3
  13. package/dist/resource/inference.d.ts.map +1 -1
  14. package/dist/resource/inference.js +7 -7
  15. package/dist/resource/invoker.d.ts +13 -13
  16. package/dist/resource/invoker.d.ts.map +1 -1
  17. package/dist/resource/invoker.js +26 -23
  18. package/dist/resource/repository.d.ts +13 -7
  19. package/dist/resource/repository.d.ts.map +1 -1
  20. package/dist/resource/repository.js +23 -13
  21. package/dist/routes.d.ts.map +1 -1
  22. package/dist/routes.js +46 -37
  23. package/dist/server.d.ts +10 -5
  24. package/dist/server.d.ts.map +1 -1
  25. package/dist/server.js +20 -7
  26. package/dist/service.d.ts +7 -2
  27. package/dist/service.d.ts.map +1 -1
  28. package/dist/service.js +3 -3
  29. package/dist/tests/client/operatorClient.test.js +94 -36
  30. package/dist/tests/db/db.test.js +4 -4
  31. package/dist/tests/helpers.d.ts +4 -3
  32. package/dist/tests/helpers.d.ts.map +1 -1
  33. package/dist/tests/helpers.js +8 -6
  34. package/dist/tests/integration/network.test.js +56 -9
  35. package/dist/tests/resource/invoker.test.js +14 -9
  36. package/dist/tests/resource/repository.test.js +9 -9
  37. package/dist/tests/routes.test.js +71 -38
  38. package/dist/tests/server.test.js +15 -15
  39. package/package.json +1 -1
@@ -1,39 +1,94 @@
1
1
  import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2
- import { OperatorClient, SETTLE_PAYMENT_MAX_RETRIES } from "../../client/operatorClient.js";
3
- describe("settle payment retries", () => {
4
- const payment = {
5
- voucher: {
6
- requestId: "request-1",
7
- invocationId: "invocation-1",
8
- amount: 10000,
9
- recipientAddress: "4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2",
10
- signature: "signature",
11
- expiresAt: "2030-01-01T00:00:00.000Z",
12
- invocationHash: "hash",
13
- publicKey: "public-key",
14
- },
15
- };
16
- const ok = () => new Response(JSON.stringify({ success: true }), { status: 200 });
17
- const status = (code, headers) => new Response(null, { status: code, headers });
18
- const networkError = (code) => new TypeError("fetch failed", { cause: Object.assign(new Error(code), { code }) });
19
- let fetchMock;
20
- beforeEach(() => {
21
- vi.useFakeTimers();
22
- vi.spyOn(console, "error").mockImplementation(() => { });
23
- fetchMock = vi.fn();
24
- vi.stubGlobal("fetch", fetchMock);
2
+ import { AUTHORIZE_MAX_RETRIES, OPERATOR_MAX_RETRIES, OperatorClient } from "../../client/operatorClient.js";
3
+ import { OPERATOR_AUTH_SCHEME, operatorRequestPayload } from "../../client/operatorAuth.js";
4
+ import { verifyData } from "../../crypto/utils.js";
5
+ import { generateBase64KeyPair } from "../helpers.js";
6
+ const keys = generateBase64KeyPair();
7
+ const client = () => new OperatorClient("https://operator.example/v1", keys.publicKey, keys.privateKey);
8
+ const payment = {
9
+ voucher: {
10
+ requestId: "request-1",
11
+ invocationId: "invocation-1",
12
+ amount: 10000,
13
+ recipientOrganizationId: "org_provider",
14
+ signature: "signature",
15
+ expiresAt: "2030-01-01T00:00:00.000Z",
16
+ invocationHash: "hash",
17
+ publicKey: "public-key",
18
+ },
19
+ };
20
+ const ok = (body = { success: true }) => new Response(JSON.stringify(body), { status: 200 });
21
+ const status = (code, headers) => new Response(null, { status: code, headers });
22
+ const refusal = (code, error) => new Response(JSON.stringify({ error }), { status: code });
23
+ const networkError = (code) => new TypeError("fetch failed", { cause: Object.assign(new Error(code), { code }) });
24
+ let fetchMock;
25
+ beforeEach(() => {
26
+ vi.useFakeTimers();
27
+ vi.spyOn(console, "error").mockImplementation(() => { });
28
+ fetchMock = vi.fn();
29
+ vi.stubGlobal("fetch", fetchMock);
30
+ });
31
+ afterEach(() => {
32
+ vi.useRealTimers();
33
+ vi.unstubAllGlobals();
34
+ vi.restoreAllMocks();
35
+ });
36
+ describe("request signing", () => {
37
+ test("signs the method, path, body hash and timestamp with the node key", async () => {
38
+ fetchMock.mockResolvedValue(ok());
39
+ await client().capture(payment);
40
+ const [url, init] = fetchMock.mock.calls[0];
41
+ expect(url).toBe("https://operator.example/v1/payments/capture");
42
+ const [scheme, encoded] = init.headers.Authorization.split(" ");
43
+ expect(scheme).toBe(OPERATOR_AUTH_SCHEME);
44
+ const credentials = JSON.parse(Buffer.from(encoded, "base64url").toString());
45
+ expect(credentials.publicKey).toBe(keys.publicKey);
46
+ const payload = operatorRequestPayload("POST", "/v1/payments/capture", JSON.parse(init.body), credentials.timestamp);
47
+ expect(verifyData(payload, keys.publicKey, credentials.signature)).toBe(true);
48
+ });
49
+ });
50
+ describe("session", () => {
51
+ test("returns the node's organization, sending the enrollment token when given", async () => {
52
+ fetchMock.mockResolvedValue(ok({ organizationId: "org_provider" }));
53
+ await expect(client().session("enroll-1")).resolves.toEqual({ organizationId: "org_provider" });
54
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ enrollmentToken: "enroll-1" });
25
55
  });
26
- afterEach(() => {
27
- vi.useRealTimers();
28
- vi.unstubAllGlobals();
29
- vi.restoreAllMocks();
56
+ test("throws the operator's refusal", async () => {
57
+ fetchMock.mockResolvedValue(refusal(403, "Node is not enrolled in an organization"));
58
+ await expect(client().session()).rejects.toThrow("Node is not enrolled in an organization");
59
+ });
60
+ });
61
+ describe("authorize", () => {
62
+ test("reports a hold as ok", async () => {
63
+ fetchMock.mockResolvedValue(ok());
64
+ await expect(client().authorize(payment.voucher)).resolves.toEqual({ ok: true });
65
+ });
66
+ test.each([[402, "Insufficient balance"], [403, "Payer organization is frozen"], [422, "Recipient organization can't receive payouts"]])("passes a %i refusal through without retrying", async (code, error) => {
67
+ fetchMock.mockResolvedValue(refusal(code, error));
68
+ await expect(client().authorize(payment.voucher)).resolves.toEqual({ ok: false, status: code, error });
69
+ expect(fetchMock).toHaveBeenCalledTimes(1);
30
70
  });
71
+ test("reports a persistent server error as 503 after its few retries", async () => {
72
+ fetchMock.mockResolvedValue(status(500));
73
+ const pending = client().authorize(payment.voucher);
74
+ await vi.runAllTimersAsync();
75
+ await expect(pending).resolves.toMatchObject({ ok: false, status: 503 });
76
+ expect(fetchMock).toHaveBeenCalledTimes(1 + AUTHORIZE_MAX_RETRIES);
77
+ });
78
+ test("reports an unreachable operator as 503", async () => {
79
+ fetchMock.mockRejectedValue(networkError("ECONNREFUSED"));
80
+ const pending = client().authorize(payment.voucher);
81
+ await vi.runAllTimersAsync();
82
+ await expect(pending).resolves.toEqual({ ok: false, status: 503, error: "Payment operator is unavailable" });
83
+ });
84
+ });
85
+ describe("capture and release retries", () => {
31
86
  test("retries 429 responses with exponential backoff, then succeeds", async () => {
32
87
  fetchMock
33
88
  .mockResolvedValueOnce(status(429))
34
89
  .mockResolvedValueOnce(status(429))
35
90
  .mockResolvedValueOnce(ok());
36
- const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
91
+ const pending = client().capture(payment);
37
92
  await vi.advanceTimersByTimeAsync(0);
38
93
  expect(fetchMock).toHaveBeenCalledTimes(1);
39
94
  await vi.advanceTimersByTimeAsync(999);
@@ -49,7 +104,7 @@ describe("settle payment retries", () => {
49
104
  });
50
105
  test("waits for Retry-After when it is longer than the backoff", async () => {
51
106
  fetchMock.mockResolvedValueOnce(status(429, { "retry-after": "30" })).mockResolvedValueOnce(ok());
52
- const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
107
+ const pending = client().release(payment.voucher);
53
108
  await vi.advanceTimersByTimeAsync(29_999);
54
109
  expect(fetchMock).toHaveBeenCalledTimes(1);
55
110
  await vi.advanceTimersByTimeAsync(1);
@@ -58,7 +113,7 @@ describe("settle payment retries", () => {
58
113
  });
59
114
  test.each([408, 500, 502, 503, 504])("retries %i responses", async (code) => {
60
115
  fetchMock.mockResolvedValueOnce(status(code)).mockResolvedValueOnce(ok());
61
- const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
116
+ const pending = client().capture(payment);
62
117
  await vi.runAllTimersAsync();
63
118
  await pending;
64
119
  expect(fetchMock).toHaveBeenCalledTimes(2);
@@ -66,31 +121,34 @@ describe("settle payment retries", () => {
66
121
  });
67
122
  test.each([400, 401, 404, 409, 422])("does not retry %i responses", async (code) => {
68
123
  fetchMock.mockResolvedValue(status(code));
69
- await new OperatorClient("https://operator.example/v1").settlePayment(payment);
124
+ await client().capture(payment);
70
125
  expect(fetchMock).toHaveBeenCalledTimes(1);
71
126
  expect(console.error).toHaveBeenCalledOnce();
72
127
  });
73
128
  test.each(["ECONNREFUSED", "ECONNRESET"])("retries %s network errors", async (code) => {
74
129
  fetchMock.mockRejectedValueOnce(networkError(code)).mockResolvedValueOnce(ok());
75
- const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
130
+ const pending = client().capture(payment);
76
131
  await vi.runAllTimersAsync();
77
132
  await pending;
78
133
  expect(fetchMock).toHaveBeenCalledTimes(2);
79
134
  });
80
135
  test("rethrows the last network error once retries are exhausted", async () => {
81
136
  fetchMock.mockRejectedValue(networkError("ECONNREFUSED"));
82
- const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
137
+ const pending = client().capture(payment);
83
138
  const assertion = expect(pending).rejects.toThrow("fetch failed");
84
139
  await vi.runAllTimersAsync();
85
140
  await assertion;
86
- expect(fetchMock).toHaveBeenCalledTimes(1 + SETTLE_PAYMENT_MAX_RETRIES);
141
+ expect(fetchMock).toHaveBeenCalledTimes(1 + OPERATOR_MAX_RETRIES);
87
142
  });
88
143
  test("gives up after the maximum number of retries", async () => {
89
144
  fetchMock.mockResolvedValue(status(429));
90
- const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
145
+ const pending = client().capture(payment);
91
146
  await vi.runAllTimersAsync();
92
147
  await pending;
93
- expect(fetchMock).toHaveBeenCalledTimes(1 + SETTLE_PAYMENT_MAX_RETRIES);
148
+ expect(fetchMock).toHaveBeenCalledTimes(1 + OPERATOR_MAX_RETRIES);
94
149
  expect(console.error).toHaveBeenCalledOnce();
95
150
  });
151
+ test("throws when no operator URL is configured", async () => {
152
+ await expect(new OperatorClient(undefined, keys.publicKey, keys.privateKey).capture(payment)).rejects.toThrow("No operator URL is configured");
153
+ });
96
154
  });
@@ -17,7 +17,7 @@ describe("openDatabase", () => {
17
17
  const filename = path.join(dir, "node.db");
18
18
  await (await openDatabase(filename)).close();
19
19
  const db = await openDatabase(filename);
20
- expect(await db.get("PRAGMA user_version")).toEqual({ user_version: 5 });
20
+ expect(await db.get("PRAGMA user_version")).toEqual({ user_version: 6 });
21
21
  await db.close();
22
22
  });
23
23
  test("rebuilds resource tables from the old schema and keeps peers", async () => {
@@ -61,7 +61,7 @@ describe("openDatabase", () => {
61
61
  expect(await reopened.all("SELECT id FROM held_grant")).toEqual([{ id: "g" }]);
62
62
  await reopened.close();
63
63
  });
64
- test("rebuilds a version 4 manifest table so inference functions can omit cost_micro_usdc", async () => {
64
+ test("rebuilds a version 4 manifest table into the micro-USD, organization-recipient schema", async () => {
65
65
  const filename = path.join(dir, "v4.db");
66
66
  const old = await open({ filename, driver: sqlite3.Database });
67
67
  await old.exec(`
@@ -74,8 +74,8 @@ describe("openDatabase", () => {
74
74
  const db = await openDatabase(filename);
75
75
  const functionColumns = await db.all("PRAGMA table_info(resource_function)");
76
76
  expect(functionColumns.map(column => column.name)).toContain("kind");
77
- await db.run(`INSERT INTO resource_function_payment_manifest (resource_function_id, input_cost_micro_usdc_per_million_tokens,
78
- output_cost_micro_usdc_per_million_tokens, recipient_address, created_at, updated_at) VALUES (1, 3, 15, 'r', 'now', 'now')`);
77
+ await db.run(`INSERT INTO resource_function_payment_manifest (resource_function_id, input_cost_micro_usd_per_million_tokens,
78
+ output_cost_micro_usd_per_million_tokens, recipient_organization_id, created_at, updated_at) VALUES (1, 3, 15, 'r', 'now', 'now')`);
79
79
  await db.close();
80
80
  });
81
81
  });
@@ -4,14 +4,15 @@ export declare function generateBase64KeyPair(): {
4
4
  publicKey: string;
5
5
  privateKey: string;
6
6
  };
7
- export declare const RECIPIENT = "4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2";
7
+ /** The operator organization the fixture provider is enrolled in. */
8
+ export declare const ORGANIZATION = "org_provider";
8
9
  export declare const COST = 10000;
9
10
  export declare function testConfig(overrides?: Partial<NodeConfig>): NodeConfig;
10
11
  /** The fixture provider's resource: paid `temperature` and free `humidity`. */
11
12
  export declare function weatherResource(baseUrl?: string): StellariumResource;
12
13
  export declare const INFERENCE_RATES: {
13
- inputCostMicroUsdcPerMillionTokens: number;
14
- outputCostMicroUsdcPerMillionTokens: number;
14
+ inputCostMicroUsdPerMillionTokens: number;
15
+ outputCostMicroUsdPerMillionTokens: number;
15
16
  };
16
17
  /**
17
18
  * A fixture provider's model: paid inference `complete`, which counts one token per four prompt characters
@@ -1 +1 @@
1
- {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../src/tests/helpers.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AACpE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEhD,wBAAgB,qBAAqB;IAM7B,SAAS;IACT,UAAU;EAEjB;AAED,eAAO,MAAM,SAAS,iDAAiD,CAAC;AACxE,eAAO,MAAM,IAAI,QAAQ,CAAC;AAE1B,wBAAgB,UAAU,CAAC,SAAS,GAAE,OAAO,CAAC,UAAU,CAAM,GAAG,UAAU,CAU1E;AAED,+EAA+E;AAC/E,wBAAgB,eAAe,CAAC,OAAO,SAA0B,GAAG,kBAAkB,CAuBrF;AAED,eAAO,MAAM,eAAe;IAAK,kCAAkC;IAAa,mCAAmC;CAAc,CAAC;AAElI;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,SAA0B,GAAG,kBAAkB,CA0BnF;AAED,eAAO,MAAM,iBAAiB,sHAAoH,CAAC"}
1
+ {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../src/tests/helpers.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AACpE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEhD,wBAAgB,qBAAqB;IAM7B,SAAS;IACT,UAAU;EAEjB;AAED,qEAAqE;AACrE,eAAO,MAAM,YAAY,iBAAiB,CAAC;AAC3C,eAAO,MAAM,IAAI,QAAQ,CAAC;AAE1B,wBAAgB,UAAU,CAAC,SAAS,GAAE,OAAO,CAAC,UAAU,CAAM,GAAG,UAAU,CAW1E;AAED,+EAA+E;AAC/E,wBAAgB,eAAe,CAAC,OAAO,SAA0B,GAAG,kBAAkB,CAuBrF;AAED,eAAO,MAAM,eAAe;IAAK,iCAAiC;IAAa,kCAAkC;CAAc,CAAC;AAEhI;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,SAA0B,GAAG,kBAAkB,CA0BnF;AAED,eAAO,MAAM,iBAAiB,sHAAoH,CAAC"}
@@ -9,7 +9,8 @@ export function generateBase64KeyPair() {
9
9
  privateKey: privateKey.toString("base64url"),
10
10
  };
11
11
  }
12
- export const RECIPIENT = "4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2";
12
+ /** The operator organization the fixture provider is enrolled in. */
13
+ export const ORGANIZATION = "org_provider";
13
14
  export const COST = 10000;
14
15
  export function testConfig(overrides = {}) {
15
16
  return {
@@ -17,8 +18,9 @@ export function testConfig(overrides = {}) {
17
18
  ...generateBase64KeyPair(),
18
19
  apiKey: "secret",
19
20
  operatorUrl: "http://operator.invalid/v1",
20
- costLimitMicroUsdc: 100_000,
21
- inferenceCostLimitMicroUsdc: 100_000,
21
+ organizationId: ORGANIZATION,
22
+ costLimitMicroUsd: 100_000,
23
+ inferenceCostLimitMicroUsd: 100_000,
22
24
  ...overrides,
23
25
  };
24
26
  }
@@ -36,7 +38,7 @@ export function weatherResource(baseUrl = "http://127.0.0.1:3002") {
36
38
  parameters: [{ name: "city", type: "string" }],
37
39
  returnType: "int32",
38
40
  exec: async (args) => (args.find((arg) => arg.name === "city")?.value === "Oslo" ? 12 : 20),
39
- paymentManifest: { costMicroUsdc: COST, recipientAddress: RECIPIENT },
41
+ paymentManifest: { costMicroUsd: COST },
40
42
  },
41
43
  {
42
44
  name: "humidity",
@@ -47,7 +49,7 @@ export function weatherResource(baseUrl = "http://127.0.0.1:3002") {
47
49
  ],
48
50
  };
49
51
  }
50
- export const INFERENCE_RATES = { inputCostMicroUsdcPerMillionTokens: 3_000_000, outputCostMicroUsdcPerMillionTokens: 15_000_000 };
52
+ export const INFERENCE_RATES = { inputCostMicroUsdPerMillionTokens: 3_000_000, outputCostMicroUsdPerMillionTokens: 15_000_000 };
51
53
  /**
52
54
  * A fixture provider's model: paid inference `complete`, which counts one token per four prompt characters
53
55
  * and answers with `maxTokens` tokens, a restricted paid copy `completePrivate`, and free inference `draft`.
@@ -64,7 +66,7 @@ export function modelResource(baseUrl = "http://127.0.0.1:3002") {
64
66
  value: `echo: ${prompt(args)}`,
65
67
  usage: { inputTokens: Math.ceil(prompt(args).length / 4), outputTokens: Number(args.find((arg) => arg.name === "maxTokens")?.value) },
66
68
  }),
67
- paymentManifest: { ...INFERENCE_RATES, recipientAddress: RECIPIENT },
69
+ paymentManifest: { ...INFERENCE_RATES },
68
70
  };
69
71
  return {
70
72
  id: 2,
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
7
7
  import { verifyData } from "../../crypto/utils.js";
8
8
  import { paymentVoucherPayload } from "../../resource/invoker.js";
9
9
  import { StellariumNode } from "../../server.js";
10
- import { COST, generateBase64KeyPair, INFERENCE_RATES, modelResource, RECIPIENT, temperatureSource, weatherResource } from "../helpers.js";
10
+ import { COST, generateBase64KeyPair, INFERENCE_RATES, modelResource, ORGANIZATION, temperatureSource, weatherResource } from "../helpers.js";
11
11
  /** A free function only callers with a grant from the provider may run. */
12
12
  function vaultResource(baseUrl) {
13
13
  return {
@@ -35,13 +35,18 @@ function freePort() {
35
35
  }
36
36
  /**
37
37
  * A provider node hosting the paid weather resource, a caller node that bootstraps from it,
38
- * and a fake operator that records settlements. Everything runs in-process over real HTTP.
38
+ * and a fake operator that enrolls the provider and records holds and captures. Everything runs in-process over real HTTP.
39
39
  */
40
40
  // Each test starts real HTTP nodes on SQLite files, which can take several seconds on a loaded CI runner.
41
41
  describe("two-node network", { timeout: 15_000 }, () => {
42
42
  const servers = [];
43
43
  const settlements = [];
44
44
  const submissions = [];
45
+ const authorizations = [];
46
+ const releases = [];
47
+ const sessions = [];
48
+ /** What the fake operator answers to the next authorizations; undefined places the hold. */
49
+ let refuseAuthorization;
45
50
  const callerKeys = generateBase64KeyPair();
46
51
  let dir;
47
52
  let operatorUrl;
@@ -76,12 +81,34 @@ describe("two-node network", { timeout: 15_000 }, () => {
76
81
  dir = await mkdtemp(path.join(tmpdir(), "stellarium-network-"));
77
82
  settlements.length = 0;
78
83
  submissions.length = 0;
84
+ authorizations.length = 0;
85
+ releases.length = 0;
86
+ sessions.length = 0;
87
+ refuseAuthorization = undefined;
79
88
  const operator = Fastify();
80
89
  servers.push(operator);
81
- operator.post("/v1/payment", async (request) => {
90
+ operator.post("/v1/nodes/session", async (request, reply) => {
91
+ if (!String(request.headers.authorization).startsWith("Stellarium ")) {
92
+ return reply.status(401).send({ error: "Unsigned request" });
93
+ }
94
+ sessions.push(request.body);
95
+ return { organizationId: ORGANIZATION };
96
+ });
97
+ operator.post("/v1/payments/authorize", async (request, reply) => {
98
+ authorizations.push(request.body);
99
+ if (refuseAuthorization) {
100
+ return reply.status(refuseAuthorization.status).send({ error: refuseAuthorization.error });
101
+ }
102
+ return { status: "held" };
103
+ });
104
+ operator.post("/v1/payments/capture", async (request) => {
82
105
  settlements.push(request.body.voucher);
83
106
  submissions.push(request.body);
84
- return { status: "confirmed" };
107
+ return { status: "captured" };
108
+ });
109
+ operator.post("/v1/payments/release", async (request) => {
110
+ releases.push(request.body);
111
+ return { status: "released" };
85
112
  });
86
113
  operatorUrl = `${await operator.listen({ port: 0, host: "127.0.0.1" })}/v1`;
87
114
  const providerPort = await freePort();
@@ -112,12 +139,13 @@ describe("two-node network", { timeout: 15_000 }, () => {
112
139
  expect(callerResources.resources).toContainEqual(expect.objectContaining({
113
140
  name: "Weather",
114
141
  baseUrl: providerUrl,
115
- functions: expect.arrayContaining([expect.objectContaining({ name: "temperature", paymentManifest: { costMicroUsdc: COST, recipientAddress: RECIPIENT } })]),
142
+ functions: expect.arrayContaining([expect.objectContaining({ name: "temperature", paymentManifest: { costMicroUsd: COST, recipientOrganizationId: ORGANIZATION } })]),
116
143
  }));
117
144
  expect(await run(caller.url)).toEqual({ returnValue: { value: 12, error: null } });
118
145
  await vi.waitFor(() => expect(settlements).toHaveLength(1));
119
146
  const [voucher] = settlements;
120
- expect(voucher).toMatchObject({ amount: COST, recipientAddress: RECIPIENT, publicKey: callerKeys.publicKey });
147
+ expect(authorizations).toEqual([{ voucher }]);
148
+ expect(voucher).toMatchObject({ amount: COST, recipientOrganizationId: ORGANIZATION, publicKey: callerKeys.publicKey });
121
149
  expect(verifyData(paymentVoucherPayload(voucher), voucher.publicKey, voucher.signature)).toBe(true);
122
150
  });
123
151
  test("the caller restarts on the same database without failing", async () => {
@@ -185,11 +213,30 @@ describe("two-node network", { timeout: 15_000 }, () => {
185
213
  });
186
214
  });
187
215
  test("the caller refuses a call that costs more than its limit", async () => {
188
- const caller = await startCaller({ costLimitMicroUsdc: COST - 1 });
216
+ const caller = await startCaller({ costLimitMicroUsd: COST - 1 });
189
217
  const result = await run(caller.url);
190
218
  expect(result.returnValue.value).toBeNull();
191
219
  expect(result.returnValue.error.message).toContain("exceeds the cost limit");
192
220
  expect(settlements).toEqual([]);
221
+ expect(authorizations).toEqual([]);
222
+ });
223
+ test("a caller with an enrollment token enrolls at startup; one without doesn't contact the operator", async () => {
224
+ sessions.length = 0;
225
+ await startCaller({ operatorUrl, enrollmentToken: "enr_caller" });
226
+ expect(sessions).toEqual([{ enrollmentToken: "enr_caller" }]);
227
+ sessions.length = 0;
228
+ await startCaller({ operatorUrl, databasePath: path.join(dir, "caller-2.db") });
229
+ expect(sessions).toEqual([]);
230
+ });
231
+ test("the provider refuses to run a call the operator won't hold, and the caller sees why", async () => {
232
+ const caller = await startCaller();
233
+ refuseAuthorization = { status: 402, error: "Insufficient balance" };
234
+ const result = await run(caller.url);
235
+ expect(result.returnValue.value).toBeNull();
236
+ expect(result.returnValue.error.message).toContain("Insufficient balance");
237
+ expect(authorizations).toHaveLength(1);
238
+ expect(settlements).toEqual([]);
239
+ expect(releases).toEqual([]);
193
240
  });
194
241
  describe("inference functions", () => {
195
242
  test("the caller prices a call with the provider's estimate, pays for it, and the provider settles its usage", async () => {
@@ -199,12 +246,12 @@ describe("two-node network", { timeout: 15_000 }, () => {
199
246
  await vi.waitFor(() => expect(submissions).toHaveLength(1));
200
247
  // 3 input tokens at $3/M plus 10 output tokens at $15/M, rounded up.
201
248
  expect(submissions[0]).toMatchObject({
202
- voucher: { kind: "inference", maxAmount: 159, maxTokens: 10, ...INFERENCE_RATES, recipientAddress: RECIPIENT, publicKey: callerKeys.publicKey },
249
+ voucher: { kind: "inference", maxAmount: 159, maxTokens: 10, ...INFERENCE_RATES, recipientOrganizationId: ORGANIZATION, publicKey: callerKeys.publicKey },
203
250
  usage: { inputTokens: 3, outputTokens: 10 },
204
251
  });
205
252
  });
206
253
  test("the caller refuses a call whose maxAmount is over its inference cost limit", async () => {
207
- const caller = await startCaller({ inferenceCostLimitMicroUsdc: 158 });
254
+ const caller = await startCaller({ inferenceCostLimitMicroUsd: 158 });
208
255
  const result = await run(caller.url, completeSource);
209
256
  expect(result.returnValue.error.message).toContain("exceeds the inference cost limit");
210
257
  expect(submissions).toEqual([]);
@@ -18,7 +18,7 @@ describe("payment voucher", () => {
18
18
  const { publicKey, privateKey } = generateBase64KeyPair();
19
19
  const invoker = new CombinationResourceInvoker({}, 100_000, publicKey, privateKey);
20
20
  const parameters = [{ name: "city", value: "Oslo" }];
21
- const voucher = invoker.createPaymentVoucher("4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2", 10000, "com.example", "Weather", "forecast", parameters);
21
+ const voucher = invoker.createPaymentVoucher("org_provider", 10000, "com.example", "Weather", "forecast", parameters);
22
22
  expect(voucher.invocationHash).toBe(invocationHash("com.example", "Weather", "forecast", parameters));
23
23
  expect(verifyData(paymentVoucherPayload(voucher), voucher.publicKey, voucher.signature)).toBe(true);
24
24
  expect(verifyData(voucher, voucher.publicKey, voucher.signature)).toBe(false);
@@ -94,7 +94,7 @@ describe("external invoke retries", () => {
94
94
  });
95
95
  test("reuses the same payment voucher on every attempt", async () => {
96
96
  fetchMock.mockResolvedValueOnce(status(503)).mockResolvedValueOnce(status(503)).mockResolvedValueOnce(ok());
97
- const invoker = createInvoker({ costMicroUsdc: 10000, recipientAddress: "4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2" });
97
+ const invoker = createInvoker({ costMicroUsd: 10000, recipientOrganizationId: "org_provider" });
98
98
  const pending = invoker.invoke(resource, "forecast", parameters);
99
99
  await vi.runAllTimersAsync();
100
100
  await pending;
@@ -104,6 +104,11 @@ describe("external invoke retries", () => {
104
104
  expect(JSON.parse(bodies[0]).paymentVoucher.requestId).toBeDefined();
105
105
  expect(JSON.parse(bodies[0]).paymentVoucher.invocationHash).toBe(invocationHash("com.example", "Weather", "forecast", parameters));
106
106
  });
107
+ test("refuses a paid function whose manifest names no recipient organization, without signing a voucher", async () => {
108
+ const result = await createInvoker({ costMicroUsd: 10000 }).invoke(resource, "forecast", parameters);
109
+ expect(fetchMock).not.toHaveBeenCalled();
110
+ expect(result.error?.message).toContain("names no recipient organization");
111
+ });
107
112
  });
108
113
  describe("restricted functions", () => {
109
114
  const resource = { path: "com.example", name: "Weather", metadata: { baseUrl: "https://remote.example" }, functions: [] };
@@ -140,7 +145,7 @@ describe("restricted functions", () => {
140
145
  fetchMock.mockResolvedValueOnce(new Response(null, { status: 503 })).mockResolvedValueOnce(new Response(JSON.stringify({ value: 42, error: null }), { status: 200 }));
141
146
  let nonce = 0;
142
147
  const authorize = vi.fn(() => ({ nonce: `nonce-${nonce++}` }));
143
- const invoker = createInvoker({ chainFor: async () => chain, authorize }, { costMicroUsdc: 10000, recipientAddress: "4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2" });
148
+ const invoker = createInvoker({ chainFor: async () => chain, authorize }, { costMicroUsd: 10000, recipientOrganizationId: "org_provider" });
144
149
  const pending = invoker.invoke(resource, "forecast", parameters);
145
150
  await vi.runAllTimersAsync();
146
151
  await expect(pending).resolves.toEqual({ value: 42, error: null });
@@ -151,10 +156,10 @@ describe("restricted functions", () => {
151
156
  });
152
157
  });
153
158
  describe("inference functions", () => {
154
- const RECIPIENT = "4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2";
159
+ const ORGANIZATION = "org_provider";
155
160
  const resource = { path: "com.example", name: "Model", metadata: { baseUrl: "https://remote.example" }, functions: [] };
156
161
  const parameters = [{ name: "prompt", value: "Hello" }, { name: "maxTokens", value: 1000 }];
157
- const manifest = { inputCostMicroUsdcPerMillionTokens: 3_000_000, outputCostMicroUsdcPerMillionTokens: 15_000_000, recipientAddress: RECIPIENT };
162
+ const manifest = { inputCostMicroUsdPerMillionTokens: 3_000_000, outputCostMicroUsdPerMillionTokens: 15_000_000, recipientOrganizationId: ORGANIZATION };
158
163
  // 2,000 input tokens and 1,000 output tokens: 6,000 + 15,000.
159
164
  const MAX_AMOUNT = 21_000;
160
165
  function createInvoker(inferenceCostLimit = 100_000, fn = { kind: "inference", paymentManifest: manifest }) {
@@ -173,9 +178,9 @@ describe("inference functions", () => {
173
178
  });
174
179
  test("inferenceCharge rounds up per million tokens", () => {
175
180
  expect(inferenceCharge(manifest, 2_000, 1_000)).toBe(21_000);
176
- expect(inferenceCharge({ inputCostMicroUsdcPerMillionTokens: 150_000, outputCostMicroUsdcPerMillionTokens: 0 }, 1, 1_000_000)).toBe(1);
177
- expect(inferenceCharge({ inputCostMicroUsdcPerMillionTokens: 0, outputCostMicroUsdcPerMillionTokens: 0 }, 5, 5)).toBe(0);
178
- expect(() => inferenceCharge({ inputCostMicroUsdcPerMillionTokens: Number.MAX_SAFE_INTEGER, outputCostMicroUsdcPerMillionTokens: 0 }, Number.MAX_SAFE_INTEGER, 0)).toThrow();
181
+ expect(inferenceCharge({ inputCostMicroUsdPerMillionTokens: 150_000, outputCostMicroUsdPerMillionTokens: 0 }, 1, 1_000_000)).toBe(1);
182
+ expect(inferenceCharge({ inputCostMicroUsdPerMillionTokens: 0, outputCostMicroUsdPerMillionTokens: 0 }, 5, 5)).toBe(0);
183
+ expect(() => inferenceCharge({ inputCostMicroUsdPerMillionTokens: Number.MAX_SAFE_INTEGER, outputCostMicroUsdPerMillionTokens: 0 }, Number.MAX_SAFE_INTEGER, 0)).toThrow();
179
184
  });
180
185
  test("asks the provider for an estimate, then signs a voucher for its maxAmount at the advertised rates", async () => {
181
186
  fetchMock.mockResolvedValueOnce(json({ inputTokens: 2_000, maxAmount: MAX_AMOUNT })).mockResolvedValueOnce(json({ value: "Hi", error: null, usage: { inputTokens: 1_500, outputTokens: 10 } }));
@@ -189,7 +194,7 @@ describe("inference functions", () => {
189
194
  const voucher = JSON.parse(runInit.body).paymentVoucher;
190
195
  expect(voucher).toMatchObject({ kind: "inference", maxAmount: MAX_AMOUNT, maxTokens: 1000, ...manifest, publicKey });
191
196
  expect(voucher.amount).toBeUndefined();
192
- expect(Object.keys(inferenceVoucherPayload(voucher))).toEqual(["kind", "requestId", "invocationId", "maxAmount", "inputCostMicroUsdcPerMillionTokens", "outputCostMicroUsdcPerMillionTokens", "maxTokens", "recipientAddress", "expiresAt", "invocationHash", "publicKey"]);
197
+ expect(Object.keys(inferenceVoucherPayload(voucher))).toEqual(["kind", "requestId", "invocationId", "maxAmount", "inputCostMicroUsdPerMillionTokens", "outputCostMicroUsdPerMillionTokens", "maxTokens", "recipientOrganizationId", "expiresAt", "invocationHash", "publicKey"]);
193
198
  expect(verifyData(inferenceVoucherPayload(voucher), publicKey, voucher.signature)).toBe(true);
194
199
  expect(Date.parse(voucher.expiresAt) - Date.now()).toBeGreaterThan(1000 * 60 * 5);
195
200
  });
@@ -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();