stellarium-ts 0.19.0 → 0.20.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 (80) hide show
  1. package/dist/client/operatorClient.d.ts +34 -3
  2. package/dist/client/operatorClient.d.ts.map +1 -1
  3. package/dist/client/operatorClient.js +15 -0
  4. package/dist/contract/repository.d.ts +49 -0
  5. package/dist/contract/repository.d.ts.map +1 -0
  6. package/dist/contract/repository.js +80 -0
  7. package/dist/contract/service.d.ts +103 -0
  8. package/dist/contract/service.d.ts.map +1 -0
  9. package/dist/contract/service.js +297 -0
  10. package/dist/contract/state.d.ts +43 -0
  11. package/dist/contract/state.d.ts.map +1 -0
  12. package/dist/contract/state.js +79 -0
  13. package/dist/db/database.d.ts +25 -0
  14. package/dist/db/database.d.ts.map +1 -0
  15. package/dist/db/database.js +1 -0
  16. package/dist/db/db.d.ts +12 -4
  17. package/dist/db/db.d.ts.map +1 -1
  18. package/dist/db/db.js +97 -24
  19. package/dist/db/postgres.d.ts +39 -0
  20. package/dist/db/postgres.d.ts.map +1 -0
  21. package/dist/db/postgres.js +132 -0
  22. package/dist/db/sqlite.d.ts +16 -0
  23. package/dist/db/sqlite.d.ts.map +1 -0
  24. package/dist/db/sqlite.js +53 -0
  25. package/dist/grant/repository.d.ts +2 -2
  26. package/dist/grant/repository.d.ts.map +1 -1
  27. package/dist/grant/repository.js +2 -2
  28. package/dist/index.d.ts +3 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -1
  31. package/dist/peer/repository.d.ts +2 -2
  32. package/dist/peer/repository.d.ts.map +1 -1
  33. package/dist/peer/repository.js +1 -1
  34. package/dist/resource/invoker.d.ts +85 -0
  35. package/dist/resource/invoker.d.ts.map +1 -1
  36. package/dist/resource/invoker.js +69 -6
  37. package/dist/resource/repository.d.ts +51 -10
  38. package/dist/resource/repository.d.ts.map +1 -1
  39. package/dist/resource/repository.js +116 -61
  40. package/dist/resource/settlement.d.ts +5 -0
  41. package/dist/resource/settlement.d.ts.map +1 -1
  42. package/dist/resource/settlement.js +19 -0
  43. package/dist/routes.d.ts.map +1 -1
  44. package/dist/routes.js +161 -25
  45. package/dist/server.d.ts +30 -1
  46. package/dist/server.d.ts.map +1 -1
  47. package/dist/server.js +80 -5
  48. package/dist/service.d.ts +27 -2
  49. package/dist/service.d.ts.map +1 -1
  50. package/dist/service.js +16 -1
  51. package/dist/startup.d.ts +2 -2
  52. package/dist/startup.d.ts.map +1 -1
  53. package/dist/startup.js +43 -34
  54. package/dist/tests/contract/repository.test.d.ts +2 -0
  55. package/dist/tests/contract/repository.test.d.ts.map +1 -0
  56. package/dist/tests/contract/repository.test.js +68 -0
  57. package/dist/tests/contract/routes.test.d.ts +2 -0
  58. package/dist/tests/contract/routes.test.d.ts.map +1 -0
  59. package/dist/tests/contract/routes.test.js +252 -0
  60. package/dist/tests/contract/state-routes.test.d.ts +2 -0
  61. package/dist/tests/contract/state-routes.test.d.ts.map +1 -0
  62. package/dist/tests/contract/state-routes.test.js +158 -0
  63. package/dist/tests/contract/state.test.d.ts +2 -0
  64. package/dist/tests/contract/state.test.d.ts.map +1 -0
  65. package/dist/tests/contract/state.test.js +57 -0
  66. package/dist/tests/db/db.test.js +1 -1
  67. package/dist/tests/db/postgres.test.d.ts +2 -0
  68. package/dist/tests/db/postgres.test.d.ts.map +1 -0
  69. package/dist/tests/db/postgres.test.js +43 -0
  70. package/dist/tests/grant/service.test.js +3 -4
  71. package/dist/tests/helpers.d.ts +8 -0
  72. package/dist/tests/helpers.d.ts.map +1 -1
  73. package/dist/tests/helpers.js +37 -0
  74. package/dist/tests/integration/network.test.js +74 -4
  75. package/dist/tests/peer/repository.test.js +3 -4
  76. package/dist/tests/resource/repository.test.js +3 -4
  77. package/dist/tests/routes.test.js +10 -11
  78. package/dist/tests/server.test.js +30 -0
  79. package/dist/tests/startup.test.js +4 -5
  80. package/package.json +5 -2
@@ -0,0 +1,252 @@
1
+ import { generateKeyPairSync } from "node:crypto";
2
+ import Fastify from "fastify";
3
+ import { createLocalJWKSet, exportJWK, SignJWT } from "jose";
4
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
5
+ import { CombinationResourceInvoker, MAX_CONTRACT_DEPTH } from "../../resource/invoker.js";
6
+ import routes from "../../routes.js";
7
+ import { createService } from "../../service.js";
8
+ import { DIALECTS, generateBase64KeyPair, ORGANIZATION, openTestDatabase, testConfig, weatherResource } from "../helpers.js";
9
+ /** 1 micro-USD a token. */
10
+ const PRICE = 1_000_000;
11
+ const FEE = 5_000;
12
+ const SUM = "contract com.example.Sum(a: int32, b: int32): int32 {\n return a + b\n}";
13
+ const sumParameters = [{ name: "a", value: 2 }, { name: "b", value: 3 }];
14
+ describe.each(DIALECTS)("contracts (%s)", (dialect) => {
15
+ const operatorKeys = generateKeyPairSync("ed25519");
16
+ const caller = generateBase64KeyPair();
17
+ // Signs vouchers as another node would.
18
+ const callerInvoker = new CombinationResourceInvoker(undefined, 100_000, caller.publicKey, caller.privateKey);
19
+ let db;
20
+ let config;
21
+ let service;
22
+ let fastify;
23
+ let spies;
24
+ beforeEach(async () => {
25
+ db = await openTestDatabase(dialect);
26
+ config = testConfig({ priceMicroUsdPerMillionTokens: PRICE, contractDeployFeeMicroUsd: FEE });
27
+ const jwk = await exportJWK(operatorKeys.publicKey);
28
+ service = createService(db, [weatherResource()], config, { runTokenKeys: createLocalJWKSet({ keys: [{ ...jwk, alg: "EdDSA" }] }) });
29
+ const client = service.operatorClient;
30
+ spies = {
31
+ authorizeRun: vi.spyOn(client, "authorizeRun").mockResolvedValue({ ok: true, heldMicroUsd: 50_000, maxTokens: 0 }),
32
+ captureRun: vi.spyOn(client, "captureRun").mockResolvedValue(undefined),
33
+ releaseRun: vi.spyOn(client, "releaseRun").mockResolvedValue(undefined),
34
+ authorize: vi.spyOn(client, "authorize").mockResolvedValue({ ok: true }),
35
+ capture: vi.spyOn(client, "capture").mockResolvedValue(undefined),
36
+ release: vi.spyOn(client, "release").mockResolvedValue(undefined),
37
+ authorizeContractRun: vi.spyOn(client, "authorizeContractRun").mockResolvedValue({ ok: true, free: false }),
38
+ };
39
+ fastify = Fastify();
40
+ await fastify.register(routes, { prefix: "/v1", service });
41
+ await fastify.ready();
42
+ });
43
+ afterEach(async () => {
44
+ await fastify.close();
45
+ await db.close();
46
+ vi.restoreAllMocks();
47
+ });
48
+ // Settlement is scheduled with setTimeout(0) after the response is sent.
49
+ const flushSettlement = () => new Promise((resolve) => setTimeout(resolve, 10));
50
+ /** A token the operator would issue to `organizationId`'s client on this node. */
51
+ const runToken = (organizationId) => new SignJWT({ org: organizationId })
52
+ .setProtectedHeader({ alg: "EdDSA" })
53
+ .setIssuer("stellarium")
54
+ .setSubject("cli_test")
55
+ .setAudience(config.publicKey)
56
+ .setIssuedAt()
57
+ .setExpirationTime("15m")
58
+ .sign(operatorKeys.privateKey);
59
+ const deploy = (source, token = "secret", description) => fastify.inject({
60
+ method: "POST", url: "/v1/contracts", headers: { authorization: `Bearer ${token}` }, payload: { source, description },
61
+ });
62
+ const run = (source, token = "secret") => fastify.inject({
63
+ method: "POST", url: "/v1/run", headers: { authorization: `Bearer ${token}` }, payload: { source },
64
+ });
65
+ const callFromPeer = (paymentVoucher, contractDepth, parameters = sumParameters) => fastify.inject({
66
+ method: "POST",
67
+ url: "/v1/run/resource",
68
+ payload: { resourcePath: "com.example", resourceName: "Sum", functionName: "run", parameters, paymentVoucher, contractDepth },
69
+ });
70
+ const sumVoucher = (price = PRICE, parameters = sumParameters) => callerInvoker.createContractVoucher(ORGANIZATION, price, 50_000, "com.example", "Sum", "run", parameters);
71
+ describe("POST /v1/contracts", () => {
72
+ test("deploys a contract as a resource with one function, run, at the node's price", async () => {
73
+ const response = await deploy(SUM, "secret", "Adds two numbers");
74
+ expect(response.statusCode).toBe(201);
75
+ expect(response.json()).toEqual({ path: "com.example", name: "Sum", version: "1", feeMicroUsd: 0 });
76
+ const resources = (await fastify.inject({ method: "GET", url: "/v1/resources" })).json().resources;
77
+ expect(resources.map(r => r.name)).toEqual(["Weather", "Sum"]);
78
+ expect(resources[1]).toMatchObject({
79
+ path: "com.example",
80
+ description: "Adds two numbers",
81
+ functions: [{
82
+ name: "run",
83
+ kind: "contract",
84
+ returnType: "int32",
85
+ parameters: [{ name: "a", type: "int32" }, { name: "b", type: "int32" }],
86
+ paymentManifest: { priceMicroUsdPerMillionTokens: PRICE, recipientOrganizationId: ORGANIZATION },
87
+ }],
88
+ });
89
+ const contracts = (await fastify.inject({ method: "GET", url: "/v1/contracts" })).json();
90
+ expect(contracts).toMatchObject({ contracts: [{ path: "com.example", name: "Sum", version: "1", source: SUM, ownerOrganizationId: null }], nextOffset: null });
91
+ expect(spies.authorizeRun).not.toHaveBeenCalled();
92
+ });
93
+ test.each([
94
+ ["a request script", "request R {\n return 1\n}", 400, "not a request"],
95
+ ["a contract without a return type", "contract com.example.Sum(a: int32) {\n return a\n}", 400, "return type"],
96
+ ["a contract without a path", "contract Sum(a: int32): int32 {\n return a\n}", 400, "path"],
97
+ ["a script that doesn't parse", "contract com.example.Sum(: int32 {", 400, "Invalid contract"],
98
+ ["a registered resource's name", "contract com.example.Weather: int32 {\n return 1\n}", 409, "already a resource"],
99
+ ])("refuses %s", async (_, source, status, error) => {
100
+ const response = await deploy(source);
101
+ expect(response.statusCode).toBe(status);
102
+ expect(response.json().error).toContain(error);
103
+ });
104
+ test("refuses an existing version, and a source over the size limit", async () => {
105
+ await deploy(SUM);
106
+ expect((await deploy(SUM)).statusCode).toBe(409);
107
+ config.maxContractSourceBytes = 10;
108
+ expect((await deploy(SUM.replace("Sum", "Other"))).statusCode).toBe(413);
109
+ });
110
+ test("requires the API key or a run token", async () => {
111
+ expect((await fastify.inject({ method: "POST", url: "/v1/contracts", payload: { source: SUM } })).statusCode).toBe(401);
112
+ expect((await deploy(SUM, "wrong")).statusCode).toBe(401);
113
+ });
114
+ test("charges another organization the deploy fee from its run, and makes it the owner", async () => {
115
+ const response = await deploy(SUM, await runToken("org_caller"));
116
+ expect(response.statusCode).toBe(201);
117
+ expect(response.json()).toMatchObject({ feeMicroUsd: FEE });
118
+ const [{ runId, source }] = spies.authorizeRun.mock.calls[0];
119
+ expect(source).toBe(SUM);
120
+ const [voucher] = spies.authorize.mock.calls[0];
121
+ expect(voucher).toMatchObject({ amount: FEE, recipientOrganizationId: ORGANIZATION, runId });
122
+ await flushSettlement();
123
+ expect(spies.capture).toHaveBeenCalledWith({ voucher });
124
+ expect(spies.captureRun).toHaveBeenCalledWith({ runId, tokens: 0 });
125
+ expect((await service.contractRepository.latest("com.example", "Sum"))?.ownerOrganizationId).toBe("org_caller");
126
+ });
127
+ test("doesn't charge for a deploy it refuses", async () => {
128
+ await deploy(SUM);
129
+ expect((await deploy(SUM, await runToken("org_caller"))).statusCode).toBe(409);
130
+ expect(spies.authorizeRun).not.toHaveBeenCalled();
131
+ });
132
+ test("answers the operator's refusal of the deploy fee, and saves nothing", async () => {
133
+ spies.authorizeRun.mockResolvedValue({ ok: false, status: 402, error: "Insufficient balance" });
134
+ const response = await deploy(SUM, await runToken("org_caller"));
135
+ expect(response.statusCode).toBe(402);
136
+ expect(await service.contractRepository.latest("com.example", "Sum")).toBeUndefined();
137
+ });
138
+ test("is free for the node's own organization", async () => {
139
+ const response = await deploy(SUM, await runToken(ORGANIZATION));
140
+ expect(response.json()).toMatchObject({ feeMicroUsd: 0 });
141
+ expect(spies.authorizeRun).not.toHaveBeenCalled();
142
+ });
143
+ test("only the owner adds versions", async () => {
144
+ await deploy(SUM, await runToken("org_caller"));
145
+ const next = SUM.replace("Sum(", "Sum@2(");
146
+ expect((await deploy(next, await runToken("org_other"))).statusCode).toBe(409);
147
+ expect((await deploy(next, await runToken("org_caller"))).statusCode).toBe(201);
148
+ expect((await service.contractRepository.latest("com.example", "Sum"))?.version).toBe("2");
149
+ });
150
+ });
151
+ describe("DELETE /v1/contracts/:ref", () => {
152
+ const remove = (ref, token = "secret") => fastify.inject({ method: "DELETE", url: `/v1/contracts/${ref}`, headers: { authorization: `Bearer ${token}` } });
153
+ test("lets the owner or the admin delete, and no one else", async () => {
154
+ await deploy(SUM, await runToken("org_caller"));
155
+ await deploy(SUM.replace("Sum(", "Sum@2("), await runToken("org_caller"));
156
+ expect((await remove("com.example.Sum@2", await runToken("org_other"))).statusCode).toBe(403);
157
+ expect((await remove("com.example.Sum@2", await runToken("org_caller"))).statusCode).toBe(200);
158
+ expect((await service.contractRepository.latest("com.example", "Sum"))?.version).toBe("1");
159
+ expect((await remove("com.example.Sum")).statusCode).toBe(200);
160
+ expect((await remove("com.example.Sum")).statusCode).toBe(404);
161
+ const resources = (await fastify.inject({ method: "GET", url: "/v1/resources" })).json().resources;
162
+ expect(resources.map(r => r.name)).toEqual(["Weather"]);
163
+ });
164
+ test("refuses a reference that isn't path.Name", async () => {
165
+ expect((await remove("Sum")).statusCode).toBe(400);
166
+ });
167
+ });
168
+ describe("calling a contract on this node", () => {
169
+ test("from an API-key run is free, and the contract sees no jwt", async () => {
170
+ await deploy("contract com.example.Who: string {\n if (jwt == null) {\n return \"admin\"\n }\n return jwt.org\n}");
171
+ await deploy(SUM);
172
+ const response = await run("request R {\n const sum: com.example.Sum\n const who: com.example.Who\n return { sum: sum.run(a: 1, b: 2).value, who: who.run().value }\n}");
173
+ expect(response.json()).toMatchObject({ returnValue: { sum: 3, who: "admin" } });
174
+ expect(spies.authorizeContractRun).not.toHaveBeenCalled();
175
+ });
176
+ test("from a billed run is held from the run, and the contract sees the run's jwt", async () => {
177
+ await deploy("contract com.example.Who: string {\n return jwt.org\n}");
178
+ const response = await run("request R {\n const who: com.example.Who\n return who.run().value\n}", await runToken("org_caller"));
179
+ expect(response.json()).toMatchObject({ returnValue: "org_caller" });
180
+ const [outer] = spies.authorizeRun.mock.calls[0];
181
+ const [{ voucher }] = spies.authorizeContractRun.mock.calls[0];
182
+ expect(voucher).toMatchObject({ kind: "contract", runId: outer.runId, recipientOrganizationId: ORGANIZATION, priceMicroUsdPerMillionTokens: PRICE });
183
+ await flushSettlement();
184
+ expect(spies.captureRun).toHaveBeenCalledWith({ runId: voucher.invocationId, tokens: expect.any(Number) });
185
+ expect(spies.captureRun).toHaveBeenCalledWith({ runId: outer.runId, tokens: expect.any(Number) });
186
+ });
187
+ test(`stops contracts calling each other ${MAX_CONTRACT_DEPTH} deep`, async () => {
188
+ await deploy("contract com.example.Loop(n: int32): int32 {\n const loop: com.example.Loop\n const next = loop.run(n: n + 1)\n if (next.error != null) {\n return n\n }\n return next.value\n}");
189
+ const response = await run("request R {\n const loop: com.example.Loop\n return loop.run(n: 1).value\n}");
190
+ expect(response.json()).toMatchObject({ returnValue: MAX_CONTRACT_DEPTH });
191
+ });
192
+ });
193
+ describe("calling a contract from another node", () => {
194
+ beforeEach(async () => {
195
+ await deploy(SUM);
196
+ });
197
+ test("authorizes a run with the voucher, runs the contract, and captures its tokens", async () => {
198
+ const voucher = sumVoucher();
199
+ const response = await callFromPeer(voucher);
200
+ expect(response.statusCode).toBe(200);
201
+ expect(response.json()).toEqual({ value: 5, error: null });
202
+ expect(spies.authorizeContractRun).toHaveBeenCalledWith({ voucher, source: SUM });
203
+ await flushSettlement();
204
+ expect(spies.captureRun).toHaveBeenCalledWith({ runId: voucher.invocationId, tokens: 4 });
205
+ });
206
+ test("captures a contract that fails, which reports the failure as the call's error", async () => {
207
+ await deploy("contract com.example.Div(a: int32): int32 {\n return 1 / a\n}");
208
+ const parameters = [{ name: "a", value: 0 }];
209
+ const voucher = callerInvoker.createContractVoucher(ORGANIZATION, PRICE, 50_000, "com.example", "Div", "run", parameters);
210
+ const response = await fastify.inject({
211
+ method: "POST", url: "/v1/run/resource",
212
+ payload: { resourcePath: "com.example", resourceName: "Div", functionName: "run", parameters, paymentVoucher: voucher },
213
+ });
214
+ expect(response.json()).toMatchObject({ value: null, error: { message: expect.stringContaining("com.example.Div@1 failed") } });
215
+ await flushSettlement();
216
+ expect(spies.captureRun).toHaveBeenCalledWith({ runId: voucher.invocationId, tokens: expect.any(Number) });
217
+ });
218
+ test("runs unbilled when the operator says the caller is this node's organization", async () => {
219
+ spies.authorizeContractRun.mockResolvedValue({ ok: true, free: true });
220
+ expect((await callFromPeer(sumVoucher())).json()).toEqual({ value: 5, error: null });
221
+ await flushSettlement();
222
+ expect(spies.captureRun).not.toHaveBeenCalled();
223
+ });
224
+ test("answers the operator's refusal without running the contract", async () => {
225
+ spies.authorizeContractRun.mockResolvedValue({ ok: false, status: 402, error: "Insufficient balance" });
226
+ const response = await callFromPeer(sumVoucher());
227
+ expect(response.statusCode).toBe(402);
228
+ expect(response.json()).toEqual({ error: "Insufficient balance" });
229
+ });
230
+ test.each([
231
+ ["no voucher", () => undefined, 402],
232
+ ["a voucher below this node's price", () => sumVoucher(PRICE - 1), 400],
233
+ ["a voucher for other parameters", () => sumVoucher(PRICE, [{ name: "a", value: 1 }, { name: "b", value: 1 }]), 400],
234
+ ["a tampered voucher", () => ({ ...sumVoucher(), maxAmount: 1 }), 400],
235
+ ])("refuses %s", async (_, voucher, status) => {
236
+ expect((await callFromPeer(voucher())).statusCode).toBe(status);
237
+ expect(spies.authorizeContractRun).not.toHaveBeenCalled();
238
+ });
239
+ test(`refuses a call more than ${MAX_CONTRACT_DEPTH} contracts deep`, async () => {
240
+ expect((await callFromPeer(sumVoucher(), MAX_CONTRACT_DEPTH + 1)).statusCode).toBe(400);
241
+ });
242
+ test("refuses a contract voucher for a function that isn't a contract", async () => {
243
+ const parameters = [{ name: "city", value: "Oslo" }];
244
+ const paymentVoucher = callerInvoker.createContractVoucher(ORGANIZATION, PRICE, 50_000, "com.example", "Weather", "temperature", parameters);
245
+ const response = await fastify.inject({
246
+ method: "POST", url: "/v1/run/resource",
247
+ payload: { resourcePath: "com.example", resourceName: "Weather", functionName: "temperature", parameters, paymentVoucher },
248
+ });
249
+ expect(response.statusCode).toBe(400);
250
+ });
251
+ });
252
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=state-routes.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state-routes.test.d.ts","sourceRoot":"","sources":["../../../src/tests/contract/state-routes.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,158 @@
1
+ import { generateKeyPairSync } from "node:crypto";
2
+ import Fastify from "fastify";
3
+ import { createLocalJWKSet, exportJWK, SignJWT } from "jose";
4
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
5
+ import { CombinationResourceInvoker } from "../../resource/invoker.js";
6
+ import routes from "../../routes.js";
7
+ import { createService } from "../../service.js";
8
+ import { DIALECTS, generateBase64KeyPair, ORGANIZATION, openTestDatabase, testConfig, weatherResource } from "../helpers.js";
9
+ const COUNTER = "contract com.example.Counter: int32 {\n var state count: int32 = 0\n count = count + 1\n return count\n}";
10
+ const callCounter = "request R {\n const counter: com.example.Counter\n return counter.run().value\n}";
11
+ /** A free function that waits until the test lets it finish, to hold a call open. */
12
+ function gateResource(gate) {
13
+ return {
14
+ id: 3,
15
+ baseUrl: "http://127.0.0.1:3002",
16
+ metadata: {},
17
+ path: "com.example",
18
+ name: "Gate",
19
+ functions: [{ name: "wait", parameters: [], returnType: "int32", exec: async () => { await gate.open; return 1; } }],
20
+ };
21
+ }
22
+ describe.each(DIALECTS)("contract state (%s)", (dialect) => {
23
+ const operatorKeys = generateKeyPairSync("ed25519");
24
+ const peer = generateBase64KeyPair();
25
+ const peerInvoker = new CombinationResourceInvoker(undefined, 100_000, peer.publicKey, peer.privateKey);
26
+ let db;
27
+ let config;
28
+ let service;
29
+ let fastify;
30
+ let authorizeContractRun;
31
+ let letGateOpen;
32
+ beforeEach(async () => {
33
+ db = await openTestDatabase(dialect);
34
+ config = testConfig({ priceMicroUsdPerMillionTokens: 0 });
35
+ const gate = { open: new Promise(resolve => { letGateOpen = resolve; }) };
36
+ const jwk = await exportJWK(operatorKeys.publicKey);
37
+ service = createService(db, [weatherResource(), gateResource(gate)], config, { runTokenKeys: createLocalJWKSet({ keys: [{ ...jwk, alg: "EdDSA" }] }) });
38
+ const client = service.operatorClient;
39
+ vi.spyOn(client, "authorizeRun").mockResolvedValue({ ok: true, heldMicroUsd: 50_000, maxTokens: 0 });
40
+ vi.spyOn(client, "captureRun").mockResolvedValue(undefined);
41
+ vi.spyOn(client, "releaseRun").mockResolvedValue(undefined);
42
+ vi.spyOn(client, "authorize").mockResolvedValue({ ok: true });
43
+ vi.spyOn(client, "capture").mockResolvedValue(undefined);
44
+ authorizeContractRun = vi.spyOn(client, "authorizeContractRun").mockResolvedValue({ ok: true, free: false, payerOrganizationId: "org_peer" });
45
+ fastify = Fastify();
46
+ await fastify.register(routes, { prefix: "/v1", service });
47
+ await fastify.ready();
48
+ });
49
+ afterEach(async () => {
50
+ letGateOpen();
51
+ await fastify.close();
52
+ await db.close();
53
+ vi.restoreAllMocks();
54
+ });
55
+ const runToken = (organizationId) => new SignJWT({ org: organizationId })
56
+ .setProtectedHeader({ alg: "EdDSA" })
57
+ .setIssuer("stellarium")
58
+ .setSubject("cli_test")
59
+ .setAudience(config.publicKey)
60
+ .setIssuedAt()
61
+ .setExpirationTime("15m")
62
+ .sign(operatorKeys.privateKey);
63
+ const deploy = async (source, token = "secret") => {
64
+ const response = await fastify.inject({ method: "POST", url: "/v1/contracts", headers: { authorization: `Bearer ${token}` }, payload: { source } });
65
+ expect(response.statusCode).toBe(201);
66
+ };
67
+ const run = async (source, token = "secret") => (await fastify.inject({ method: "POST", url: "/v1/run", headers: { authorization: `Bearer ${token}` }, payload: { source } })).json();
68
+ const readState = (ref, token = "secret") => fastify.inject({ method: "GET", url: `/v1/contracts/${ref}/state`, headers: { authorization: `Bearer ${token}` } });
69
+ /** A call from another node, which the operator says `org_peer` pays for unless the spy says otherwise. */
70
+ const callFromPeer = async (resourceName, parameters = []) => {
71
+ const paymentVoucher = peerInvoker.createContractVoucher(ORGANIZATION, 0, 50_000, "com.example", resourceName, "run", parameters);
72
+ return (await fastify.inject({
73
+ method: "POST", url: "/v1/run/resource",
74
+ payload: { resourcePath: "com.example", resourceName, functionName: "run", parameters, paymentVoucher },
75
+ })).json();
76
+ };
77
+ test("keeps counting across calls and new versions", async () => {
78
+ await deploy(COUNTER);
79
+ expect((await run(callCounter)).returnValue).toBe(1);
80
+ expect((await run(callCounter)).returnValue).toBe(2);
81
+ // A new version can widen the type.
82
+ await deploy("contract com.example.Counter@2: int64 {\n var state count: int64 = 0\n count = count + 10\n return count\n}");
83
+ expect((await run(callCounter)).returnValue).toBe(12);
84
+ });
85
+ test("caller tells a remote caller's organization and node apart from this node's", async () => {
86
+ await deploy("contract com.example.Who: object {\n return caller\n}");
87
+ expect(await callFromPeer("Who")).toEqual({ value: { org: "org_peer", node: peer.publicKey }, error: null });
88
+ expect((await run("request R {\n const who: com.example.Who\n return who.run().value\n}")).returnValue).toEqual({ org: ORGANIZATION, node: config.publicKey });
89
+ expect((await run("request R {\n const who: com.example.Who\n return who.run().value\n}", await runToken("org_client"))).returnValue)
90
+ .toEqual({ org: "org_client", node: config.publicKey });
91
+ });
92
+ test("state(caller.org) keeps one copy per paying organization", async () => {
93
+ await deploy("contract com.example.Visits: int32 {\n var state(caller.org) visits: int32 = 0\n visits = visits + 1\n return visits\n}");
94
+ expect((await callFromPeer("Visits")).value).toBe(1);
95
+ expect((await callFromPeer("Visits")).value).toBe(2);
96
+ authorizeContractRun.mockResolvedValue({ ok: true, free: false, payerOrganizationId: "org_other" });
97
+ expect((await callFromPeer("Visits")).value).toBe(1);
98
+ expect((await run("request R {\n const visits: com.example.Visits\n return visits.run().value\n}", await runToken("org_client"))).returnValue).toBe(1);
99
+ const variables = (await readState("com.example.Visits")).json().variables;
100
+ expect(variables.map((v) => [v.scope.key, v.value])).toEqual([["org_client", 1], ["org_other", 1], ["org_peer", 2]]);
101
+ });
102
+ test("a call that fails saves nothing", async () => {
103
+ await deploy("contract com.example.Fragile(d: int32): int32 {\n var state count: int32 = 0\n count = count + 1\n return 1 / d\n}");
104
+ const result = await callFromPeer("Fragile", [{ name: "d", value: 0 }]);
105
+ expect(result.error.message).toContain("Fragile@1 failed");
106
+ expect((await readState("com.example.Fragile")).json()).toEqual({ variables: [] });
107
+ });
108
+ test("concurrent calls to a shared counter all count", async () => {
109
+ await deploy(COUNTER);
110
+ const results = await Promise.all(Array.from({ length: 8 }, () => run(callCounter)));
111
+ expect(results.map(result => result.returnValue).sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
112
+ });
113
+ test("a call that re-enters its own contract saves first, and the outer call fails rather than lose it", async () => {
114
+ await deploy("contract com.example.Again(again: boolean): int32 {\n var state count: int32 = 0\n const self: com.example.Again\n if (again) {\n const inner = self.run(again: false)\n }\n count = count + 1\n return count\n}");
115
+ const result = (await run("request R {\n const again: com.example.Again\n return again.run(again: true)\n}")).returnValue;
116
+ expect(result.error.message).toContain("its state changed during the call");
117
+ expect((await readState("com.example.Again")).json().variables).toMatchObject([{ name: "count", value: 1 }]);
118
+ });
119
+ test("a call waiting too long for the contract fails, and the earlier call still saves", async () => {
120
+ await deploy("contract com.example.Slow: int32 {\n var state count: int32 = 0\n const gate: com.example.Gate\n gate.wait()\n count = count + 1\n return count\n}");
121
+ service.contractService.lockTimeoutMs = 50;
122
+ const callSlow = "request R {\n const slow: com.example.Slow\n return slow.run()\n}";
123
+ const first = run(callSlow);
124
+ await new Promise(resolve => setTimeout(resolve, 20));
125
+ const second = (await run(callSlow)).returnValue;
126
+ letGateOpen();
127
+ expect(second.error.message).toContain("it is busy");
128
+ expect((await first).returnValue).toEqual({ value: 1, error: null });
129
+ });
130
+ test("a call that would grow its state past the cap fails and saves nothing", async () => {
131
+ config.maxContractStateBytes = 16;
132
+ await deploy("contract com.example.Notes(text: string): string {\n var state note: string = \"\"\n note = text\n return note\n}");
133
+ expect((await callFromPeer("Notes", [{ name: "text", value: "short" }])).value).toBe("short");
134
+ const result = await callFromPeer("Notes", [{ name: "text", value: "a note much longer than the cap" }]);
135
+ expect(result.error.message).toContain("more than this node's 16");
136
+ expect((await readState("com.example.Notes")).json().variables).toMatchObject([{ value: "short" }]);
137
+ });
138
+ test("only the owner and the node's admin can read state", async () => {
139
+ await deploy(COUNTER, await runToken("org_owner"));
140
+ await run(callCounter);
141
+ expect((await readState("com.example.Counter", await runToken("org_owner"))).json())
142
+ .toEqual({ variables: [{ name: "count", scope: null, value: 1, updatedAt: expect.any(String) }] });
143
+ expect((await readState("com.example.Counter")).statusCode).toBe(200);
144
+ expect((await readState("com.example.Counter", await runToken("org_other"))).statusCode).toBe(403);
145
+ expect((await readState("com.example.Counter@1")).statusCode).toBe(400);
146
+ expect((await readState("com.example.Missing")).statusCode).toBe(404);
147
+ });
148
+ test("deleting a version keeps state, and deleting the contract deletes it", async () => {
149
+ await deploy(COUNTER);
150
+ await deploy(COUNTER.replace("Counter:", "Counter@2:"));
151
+ await run(callCounter);
152
+ const remove = (ref) => fastify.inject({ method: "DELETE", url: `/v1/contracts/${ref}`, headers: { authorization: "Bearer secret" } });
153
+ await remove("com.example.Counter@2");
154
+ expect(await service.contractStateRepository.list("com.example", "Counter")).toHaveLength(1);
155
+ await remove("com.example.Counter");
156
+ expect(await service.contractStateRepository.list("com.example", "Counter")).toEqual([]);
157
+ });
158
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=state.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state.test.d.ts","sourceRoot":"","sources":["../../../src/tests/contract/state.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,57 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
2
+ import { ContractStateRepository, scopeFromKey, scopeKey } from "../../contract/state.js";
3
+ import { DIALECTS, openTestDatabase } from "../helpers.js";
4
+ const INT32 = '{"kind":"int32"}';
5
+ const write = (name, value, overrides = {}) => ({ name, scope: null, value: value, type: INT32, ...overrides });
6
+ describe.each(DIALECTS)("ContractStateRepository (%s)", (dialect) => {
7
+ let db;
8
+ let state;
9
+ beforeEach(async () => {
10
+ db = await openTestDatabase(dialect);
11
+ state = new ContractStateRepository(db);
12
+ });
13
+ afterEach(async () => {
14
+ await db.close();
15
+ });
16
+ test("stores a new variable, then updates it from the version it was read at", async () => {
17
+ expect(await state.commit("com.example", "Counter", [write("count", 1)], 1000)).toEqual({ ok: true });
18
+ const stored = await state.get("com.example", "Counter", "count", null);
19
+ expect(stored).toEqual({ value: 1, type: INT32, version: 1 });
20
+ expect(await state.commit("com.example", "Counter", [write("count", 2, { version: stored.version })], 1000)).toEqual({ ok: true });
21
+ expect(await state.get("com.example", "Counter", "count", null)).toMatchObject({ value: 2, version: 2 });
22
+ });
23
+ test("refuses to save over a copy that changed, and saves nothing of that call", async () => {
24
+ await state.commit("com.example", "Counter", [write("count", 1)], 1000);
25
+ // Read at version 1, but another call saved in between.
26
+ await state.commit("com.example", "Counter", [write("count", 2, { version: 1 })], 1000);
27
+ expect(await state.commit("com.example", "Counter", [write("total", 9), write("count", 3, { version: 1 })], 1000)).toEqual({ ok: false, reason: "conflict" });
28
+ // A second first save is a conflict too.
29
+ expect(await state.commit("com.example", "Counter", [write("count", 7)], 1000)).toEqual({ ok: false, reason: "conflict" });
30
+ expect(await state.get("com.example", "Counter", "count", null)).toMatchObject({ value: 2 });
31
+ expect(await state.get("com.example", "Counter", "total", null)).toBeUndefined();
32
+ });
33
+ test("keeps one copy per scope, and scopes keep their kind", async () => {
34
+ const scopes = [null, { kind: "string", key: "3" }, { kind: "integer", key: "3" }];
35
+ await state.commit("com.example", "Tally", scopes.map((scope, index) => write("n", index, { scope })), 1000);
36
+ expect(await Promise.all(scopes.map(scope => state.get("com.example", "Tally", "n", scope).then(stored => stored?.value)))).toEqual([0, 1, 2]);
37
+ expect(scopes.map(scope => scopeFromKey(scopeKey(scope)))).toEqual(scopes);
38
+ expect((await state.list("com.example", "Tally")).map(row => row.scope)).toEqual([null, { kind: "integer", key: "3" }, { kind: "string", key: "3" }]);
39
+ });
40
+ test("refuses a save that grows a scope past the cap, counting every variable in it", async () => {
41
+ const scope = { kind: "string", key: "org_a" };
42
+ // Each value is 12 bytes of JSON: ten letters and their quotes.
43
+ await state.commit("com.example", "Notes", [write("a", "x".repeat(10), { scope })], 20);
44
+ expect(await state.commit("com.example", "Notes", [write("b", "y".repeat(10), { scope })], 20))
45
+ .toEqual({ ok: false, reason: "too_large", scope, bytes: 24 });
46
+ expect(await state.get("com.example", "Notes", "b", scope)).toBeUndefined();
47
+ // Another scope has its own allowance.
48
+ expect(await state.commit("com.example", "Notes", [write("b", "y".repeat(10), { scope: { kind: "string", key: "org_b" } })], 20)).toEqual({ ok: true });
49
+ });
50
+ test("deletes a contract's state, and only that contract's", async () => {
51
+ await state.commit("com.example", "A", [write("n", 1)], 1000);
52
+ await state.commit("com.example", "B", [write("n", 1)], 1000);
53
+ await state.deleteContract("com.example", "A");
54
+ expect(await state.list("com.example", "A")).toEqual([]);
55
+ expect(await state.list("com.example", "B")).toHaveLength(1);
56
+ });
57
+ });
@@ -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: 6 });
20
+ expect(await db.get("PRAGMA user_version")).toEqual({ user_version: 7 });
21
21
  await db.close();
22
22
  });
23
23
  test("rebuilds resource tables from the old schema and keeps peers", async () => {
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=postgres.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres.test.d.ts","sourceRoot":"","sources":["../../../src/tests/db/postgres.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,43 @@
1
+ import { PGlite } from "@electric-sql/pglite";
2
+ import { describe, expect, test, vi } from "vitest";
3
+ import { createTables } from "../../db/db.js";
4
+ import { numberPlaceholders, PostgresDatabase } from "../../db/postgres.js";
5
+ import { pgliteDriver } from "../helpers.js";
6
+ describe("numberPlaceholders", () => {
7
+ test("numbers placeholders outside quotes", () => {
8
+ expect(numberPlaceholders("SELECT * FROM t WHERE a = ? AND b = '?' AND \"c?\" = ?")).toBe("SELECT * FROM t WHERE a = $1 AND b = '?' AND \"c?\" = $2");
9
+ });
10
+ });
11
+ describe("PostgresDatabase", () => {
12
+ test("creates its tables in the node's schema, and can be reopened", async () => {
13
+ const pg = new PGlite({ parsers: { 20: Number } });
14
+ // node-postgres sets the search path per connection; PGlite has the one.
15
+ await pg.exec("SET search_path TO node_a");
16
+ const db = await PostgresDatabase.open(pgliteDriver(pg), "node_a");
17
+ await createTables(db);
18
+ await db.run("INSERT INTO peer (base_url, public_key, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", ["http://p", "key", "p", "now", "now"]);
19
+ await createTables(db);
20
+ expect(await db.all("SELECT public_key FROM peer")).toEqual([{ public_key: "key" }]);
21
+ expect(await db.all("SELECT version FROM stellarium_schema")).toEqual([{ version: 7 }]);
22
+ const tables = await db.all("SELECT table_name FROM information_schema.tables WHERE table_schema = 'node_a' ORDER BY table_name");
23
+ expect(tables.map(table => table.table_name)).toContain("resource_function_payment_manifest");
24
+ await db.close();
25
+ });
26
+ test("refuses a schema name that isn't a plain identifier", async () => {
27
+ const exec = vi.fn();
28
+ const driver = { query: vi.fn(), exec, transaction: vi.fn(), close: vi.fn() };
29
+ await expect(PostgresDatabase.open(driver, "a; DROP TABLE peer")).rejects.toThrow(/Invalid database schema/);
30
+ expect(exec).not.toHaveBeenCalled();
31
+ });
32
+ test("rolls a failed transaction back", async () => {
33
+ const pg = new PGlite({ parsers: { 20: Number } });
34
+ const db = await PostgresDatabase.open(pgliteDriver(pg));
35
+ await createTables(db);
36
+ await expect(db.transaction(async (tx) => {
37
+ await tx.run("INSERT INTO blocked_caller (public_key, reason, blocked_at) VALUES (?, ?, ?)", ["key", null, "now"]);
38
+ throw new Error("failed");
39
+ })).rejects.toThrow("failed");
40
+ expect(await db.all("SELECT * FROM blocked_caller")).toEqual([]);
41
+ await db.close();
42
+ });
43
+ });
@@ -1,19 +1,18 @@
1
1
  import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2
- import { openDatabase } from "../../db/db.js";
3
2
  import { grantHash, grantRequestPayload, signGrant } from "../../grant/grant.js";
4
3
  import { signData } from "../../crypto/utils.js";
5
4
  import { GrantRepository } from "../../grant/repository.js";
6
5
  import { PeerRepository } from "../../peer/repository.js";
7
6
  import { GrantService, HELD_GRANT_RETENTION_MS } from "../../grant/service.js";
8
- import { generateBase64KeyPair } from "../helpers.js";
7
+ import { DIALECTS, generateBase64KeyPair, openTestDatabase } from "../helpers.js";
9
8
  const HOUR = 3_600_000;
10
9
  const weather = { resourcePath: "com.example", resourceName: "Weather" };
11
10
  const target = { resourcePath: "com.example", resourceName: "Weather", functionName: "temperature", parameters: [{ name: "city", value: "Oslo" }] };
12
- describe("GrantService", () => {
11
+ describe.each(DIALECTS)("GrantService (%s)", (dialect) => {
13
12
  const databases = [];
14
13
  const nodes = new Map();
15
14
  async function node(baseUrl, approveGrant) {
16
- const db = await openDatabase(":memory:");
15
+ const db = await openTestDatabase(dialect);
17
16
  databases.push(db);
18
17
  const keys = generateBase64KeyPair();
19
18
  const service = new GrantService(new GrantRepository(db), { baseUrl, ...keys }, approveGrant);
@@ -1,3 +1,6 @@
1
+ import { PGlite, type Transaction } from "@electric-sql/pglite";
2
+ import type { DatabaseDialect, NodeDatabase } from "../db/database.js";
3
+ import { type PostgresDriver } from "../db/postgres.js";
1
4
  import type { StellariumResource } from "../resource/repository.js";
2
5
  import type { NodeConfig } from "../service.js";
3
6
  export declare function generateBase64KeyPair(): {
@@ -20,4 +23,9 @@ export declare const INFERENCE_RATES: {
20
23
  */
21
24
  export declare function modelResource(baseUrl?: string): StellariumResource;
22
25
  export declare const temperatureSource = "request GetTemperature {\n const weather: com.example.Weather\n\n return weather.temperature(city: \"Oslo\")\n}";
26
+ /** Both databases a node can use. Suites that touch the database run against each. */
27
+ export declare const DIALECTS: DatabaseDialect[];
28
+ export declare function pgliteDriver(pg: PGlite | Transaction): PostgresDriver;
29
+ /** An empty node database: SQLite in memory, or Postgres in memory through PGlite. */
30
+ export declare function openTestDatabase(dialect?: DatabaseDialect): Promise<NodeDatabase>;
23
31
  //# sourceMappingURL=helpers.d.ts.map
@@ -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,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"}
1
+ {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../src/tests/helpers.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,KAAK,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAChE,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAEvE,OAAO,EAAoB,KAAK,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAC1E,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;AAEnJ,sFAAsF;AACtF,eAAO,MAAM,QAAQ,EAAE,eAAe,EAA2B,CAAC;AAElE,wBAAgB,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,WAAW,GAAG,cAAc,CAqBrE;AAED,sFAAsF;AACtF,wBAAsB,gBAAgB,CAAC,OAAO,GAAE,eAA0B,GAAG,OAAO,CAAC,YAAY,CAAC,CAQjG"}