stellarium-ts 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/client/operatorClient.d.ts +10 -4
  2. package/dist/client/operatorClient.d.ts.map +1 -1
  3. package/dist/client/operatorClient.js +51 -7
  4. package/dist/crypto/utils.d.ts +9 -0
  5. package/dist/crypto/utils.d.ts.map +1 -1
  6. package/dist/crypto/utils.js +30 -1
  7. package/dist/db/db.d.ts +5 -4
  8. package/dist/db/db.d.ts.map +1 -1
  9. package/dist/db/db.js +88 -12
  10. package/dist/grant/grant.d.ts +132 -0
  11. package/dist/grant/grant.d.ts.map +1 -0
  12. package/dist/grant/grant.js +158 -0
  13. package/dist/grant/repository.d.ts +57 -0
  14. package/dist/grant/repository.d.ts.map +1 -0
  15. package/dist/grant/repository.js +105 -0
  16. package/dist/grant/service.d.ts +141 -0
  17. package/dist/grant/service.d.ts.map +1 -0
  18. package/dist/grant/service.js +431 -0
  19. package/dist/index.d.ts +6 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +5 -1
  22. package/dist/peer/repository.d.ts +33 -2
  23. package/dist/peer/repository.d.ts.map +1 -1
  24. package/dist/peer/repository.js +43 -21
  25. package/dist/resource/invoker.d.ts +21 -9
  26. package/dist/resource/invoker.d.ts.map +1 -1
  27. package/dist/resource/invoker.js +69 -16
  28. package/dist/resource/repository.d.ts +47 -8
  29. package/dist/resource/repository.d.ts.map +1 -1
  30. package/dist/resource/repository.js +122 -67
  31. package/dist/routes.d.ts +8 -0
  32. package/dist/routes.d.ts.map +1 -1
  33. package/dist/routes.js +302 -48
  34. package/dist/server.d.ts +62 -0
  35. package/dist/server.d.ts.map +1 -1
  36. package/dist/server.js +119 -18
  37. package/dist/service.d.ts +24 -1
  38. package/dist/service.d.ts.map +1 -1
  39. package/dist/service.js +9 -6
  40. package/dist/startup.d.ts +14 -0
  41. package/dist/startup.d.ts.map +1 -1
  42. package/dist/startup.js +90 -76
  43. package/dist/tests/client/operatorClient.test.d.ts +2 -0
  44. package/dist/tests/client/operatorClient.test.d.ts.map +1 -0
  45. package/dist/tests/client/operatorClient.test.js +96 -0
  46. package/dist/tests/db/db.test.d.ts +2 -0
  47. package/dist/tests/db/db.test.d.ts.map +1 -0
  48. package/dist/tests/db/db.test.js +64 -0
  49. package/dist/tests/grant/grant.test.d.ts +2 -0
  50. package/dist/tests/grant/grant.test.d.ts.map +1 -0
  51. package/dist/tests/grant/grant.test.js +133 -0
  52. package/dist/tests/grant/service.test.d.ts +2 -0
  53. package/dist/tests/grant/service.test.d.ts.map +1 -0
  54. package/dist/tests/grant/service.test.js +415 -0
  55. package/dist/tests/helpers.d.ts +13 -0
  56. package/dist/tests/helpers.d.ts.map +1 -0
  57. package/dist/tests/helpers.js +49 -0
  58. package/dist/tests/integration/network.test.d.ts +2 -0
  59. package/dist/tests/integration/network.test.d.ts.map +1 -0
  60. package/dist/tests/integration/network.test.js +188 -0
  61. package/dist/tests/peer/repository.test.d.ts +2 -0
  62. package/dist/tests/peer/repository.test.d.ts.map +1 -0
  63. package/dist/tests/peer/repository.test.js +50 -0
  64. package/dist/tests/resource/invoker.test.js +132 -5
  65. package/dist/tests/resource/repository.test.d.ts +2 -0
  66. package/dist/tests/resource/repository.test.d.ts.map +1 -0
  67. package/dist/tests/resource/repository.test.js +151 -0
  68. package/dist/tests/routes.test.d.ts +2 -0
  69. package/dist/tests/routes.test.d.ts.map +1 -0
  70. package/dist/tests/routes.test.js +565 -0
  71. package/dist/tests/server.test.d.ts +2 -0
  72. package/dist/tests/server.test.d.ts.map +1 -0
  73. package/dist/tests/server.test.js +37 -0
  74. package/dist/tests/startup.test.d.ts +2 -0
  75. package/dist/tests/startup.test.d.ts.map +1 -0
  76. package/dist/tests/startup.test.js +75 -0
  77. package/package.json +2 -1
package/dist/service.d.ts CHANGED
@@ -1,13 +1,36 @@
1
1
  import { ResourceInvoker, ResourceResolver } from "requestscript";
2
+ import type { Database } from "sqlite";
2
3
  import { PeerRepository } from "./peer/repository.js";
3
4
  import { ResourceRepository, type StellariumResource } from "./resource/repository.js";
4
5
  import { OperatorClient } from "./client/operatorClient.js";
6
+ import { type ApproveGrant, GrantService } from "./grant/service.js";
7
+ /** Settings a node runs with. `StellariumNode.start` fills these from its options, then the environment. */
8
+ export interface NodeConfig {
9
+ /** URL peers use to reach this node. Also its peer name. */
10
+ baseUrl: string;
11
+ /** Ed25519 SPKI DER, base64url. */
12
+ publicKey: string;
13
+ /** Ed25519 PKCS#8 DER, base64url. */
14
+ privateKey: string;
15
+ /** Bearer token for `POST /v1/run`. When unset, `/v1/run` rejects every request. */
16
+ apiKey?: string;
17
+ /** Operator base URL, including `/v1`. Required when the node hosts paid functions. */
18
+ operatorUrl?: string;
19
+ /** Most this node will pay for one remote call. */
20
+ costLimitLamports: number;
21
+ }
5
22
  export interface Service {
23
+ config: NodeConfig;
6
24
  peerRepository: PeerRepository;
7
25
  resourceRepository: ResourceRepository;
8
26
  resourceResolver: ResourceResolver;
9
27
  resourceInvoker: ResourceInvoker;
10
28
  operatorClient: OperatorClient;
29
+ grantService: GrantService;
30
+ }
31
+ export interface ServiceOptions {
32
+ /** Decides `POST /v1/grants/request`. Without it, every request is refused. */
33
+ approveGrant?: ApproveGrant;
11
34
  }
12
- export declare function createService(resources: StellariumResource[]): Service;
35
+ export declare function createService(db: Database, resources: StellariumResource[], config: NodeConfig, options?: ServiceOptions): Service;
13
36
  //# sourceMappingURL=service.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,eAAe,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAE5E,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAEtD,OAAO,EAAE,kBAAkB,EAAE,KAAK,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAEvF,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAE5D,MAAM,WAAW,OAAO;IACpB,cAAc,EAAE,cAAc,CAAC;IAC/B,kBAAkB,EAAE,kBAAkB,CAAC;IACvC,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,eAAe,EAAE,eAAe,CAAC;IACjC,cAAc,EAAE,cAAc,CAAC;CAClC;AAED,wBAAgB,aAAa,CAAC,SAAS,EAAE,kBAAkB,EAAE,GAAG,OAAO,CAiBtE"}
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAClE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAEtD,OAAO,EAAE,kBAAkB,EAAE,KAAK,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAEvF,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAE5D,OAAO,EAAE,KAAK,YAAY,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAErE,4GAA4G;AAC5G,MAAM,WAAW,UAAU;IACvB,4DAA4D;IAC5D,OAAO,EAAE,MAAM,CAAC;IAChB,mCAAmC;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,oFAAoF;IACpF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uFAAuF;IACvF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mDAAmD;IACnD,iBAAiB,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,OAAO;IACpB,MAAM,EAAE,UAAU,CAAC;IACnB,cAAc,EAAE,cAAc,CAAC;IAC/B,kBAAkB,EAAE,kBAAkB,CAAC;IACvC,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,eAAe,EAAE,eAAe,CAAC;IACjC,cAAc,EAAE,cAAc,CAAC;IAC/B,YAAY,EAAE,YAAY,CAAC;CAC9B;AAED,MAAM,WAAW,cAAc;IAC3B,+EAA+E;IAC/E,YAAY,CAAC,EAAE,YAAY,CAAC;CAC/B;AAED,wBAAgB,aAAa,CAAC,EAAE,EAAE,QAAQ,EAAE,SAAS,EAAE,kBAAkB,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAoBtI"}
package/dist/service.js CHANGED
@@ -1,20 +1,23 @@
1
- import db from "./db/db.js";
2
1
  import { PeerRepository } from "./peer/repository.js";
3
2
  import { CombinationResourceResolver } from "./resource/resolver.js";
4
3
  import { ResourceRepository } from "./resource/repository.js";
5
4
  import { CombinationResourceInvoker } from "./resource/invoker.js";
6
5
  import { OperatorClient } from "./client/operatorClient.js";
7
- export function createService(resources) {
8
- const resourceRepository = new ResourceRepository(db, resources);
6
+ import { GrantRepository } from "./grant/repository.js";
7
+ import { GrantService } from "./grant/service.js";
8
+ export function createService(db, resources, config, options = {}) {
9
+ const resourceRepository = new ResourceRepository(db, resources, config.baseUrl);
9
10
  const resourceResolver = new CombinationResourceResolver(resourceRepository);
10
- // TODO: Replace with actual values.
11
- const resourceInvoker = new CombinationResourceInvoker(resourceRepository, 10000, process.env.SIGNING_PUBLIC_KEY, process.env.SIGNING_PRIVATE_KEY);
12
- const operatorClient = new OperatorClient(process.env.OPERATOR_URL);
11
+ const grantService = new GrantService(new GrantRepository(db), config, options.approveGrant);
12
+ const resourceInvoker = new CombinationResourceInvoker(resourceRepository, config.costLimitLamports, config.publicKey, config.privateKey, grantService);
13
+ const operatorClient = new OperatorClient(config.operatorUrl);
13
14
  return {
15
+ config,
14
16
  peerRepository: new PeerRepository(db),
15
17
  resourceRepository,
16
18
  resourceResolver,
17
19
  resourceInvoker,
18
20
  operatorClient,
21
+ grantService,
19
22
  };
20
23
  }
package/dist/startup.d.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import { FastifyBaseLogger } from "fastify";
2
2
  import { Service } from "./service.js";
3
+ /** Per request to a peer during startup. */
4
+ export declare const STARTUP_REQUEST_TIMEOUT_MS = 10000;
5
+ /** Peers and resources requested per page from the starting peer. It may return fewer. */
6
+ export declare const STARTUP_PAGE_SIZE = 100;
3
7
  export declare class StartupService {
4
8
  private readonly log;
5
9
  private readonly service;
@@ -9,8 +13,18 @@ export declare class StartupService {
9
13
  private readonly privateKey;
10
14
  constructor(log: FastifyBaseLogger, service: Service, startingPeer: string, baseUrl: string, publicKey: string, privateKey: string);
11
15
  seedFromPeer(): Promise<void>;
16
+ /** GET a JSON document from the starting peer, failing on a non-2xx response. */
17
+ private getFromStartingPeer;
18
+ /**
19
+ * Walks a paginated list on the starting peer, handing each page to `handlePage` before
20
+ * fetching the next, so the whole list is never held at once. Returns the item count.
21
+ */
22
+ private forEachPageFromStartingPeer;
23
+ private isSelf;
12
24
  populatePeers(): Promise<void>;
13
25
  populateResources(): Promise<void>;
26
+ /** PUT to every peer, logging failures instead of throwing so one bad peer doesn't stop the rest. */
27
+ private putToPeers;
14
28
  broadcastSelf(): Promise<void>;
15
29
  }
16
30
  //# sourceMappingURL=startup.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"startup.d.ts","sourceRoot":"","sources":["../src/startup.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAI5C,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAEvC,qBAAa,cAAc;IAEnB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,YAAY;IAC7B,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,UAAU;IAN/B,YACqB,GAAG,EAAE,iBAAiB,EACtB,OAAO,EAAE,OAAO,EAChB,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EACnC;IAGE,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAUlC;IAEK,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CA8BnC;IAEK,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC,CAgBvC;IAEK,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CAqEnC;CACJ"}
1
+ {"version":3,"file":"startup.d.ts","sourceRoot":"","sources":["../src/startup.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAI5C,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAEvC,4CAA4C;AAC5C,eAAO,MAAM,0BAA0B,QAAS,CAAC;AAEjD,0FAA0F;AAC1F,eAAO,MAAM,iBAAiB,MAAM,CAAC;AAKrC,qBAAa,cAAc;IAEnB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,YAAY;IAC7B,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,UAAU;IAN/B,YACqB,GAAG,EAAE,iBAAiB,EACtB,OAAO,EAAE,OAAO,EAChB,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EACnC;IAGE,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAUlC;IAED,iFAAiF;YACnE,mBAAmB;IAQjC;;;OAGG;YACW,2BAA2B;IAsBzC,OAAO,CAAC,MAAM;IAIR,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CAkBnC;IAEK,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC,CAWvC;IAED,qGAAqG;YACvF,UAAU;IAqBlB,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CA8BnC;CACJ"}
package/dist/startup.js CHANGED
@@ -1,4 +1,12 @@
1
1
  import { signData } from "./crypto/utils.js";
2
+ import { peerAnnouncementPayload } from "./peer/repository.js";
3
+ import { resourceAnnouncementPayload } from "./resource/repository.js";
4
+ /** Per request to a peer during startup. */
5
+ export const STARTUP_REQUEST_TIMEOUT_MS = 10_000;
6
+ /** Peers and resources requested per page from the starting peer. It may return fewer. */
7
+ export const STARTUP_PAGE_SIZE = 100;
8
+ // 10 minutes
9
+ const SIGNATURE_LIFETIME_MS = 1000 * 60 * 10;
2
10
  export class StartupService {
3
11
  log;
4
12
  service;
@@ -14,7 +22,7 @@ export class StartupService {
14
22
  this.publicKey = publicKey;
15
23
  this.privateKey = privateKey;
16
24
  }
17
- // If the peer list is empty, populate it using the peer start parameter.
25
+ // Learn peers and resources from the starting peer, then announce this node to every peer.
18
26
  async seedFromPeer() {
19
27
  this.log.info(`Seeding from peer ${this.startingPeer}`);
20
28
  await this.populatePeers();
@@ -23,101 +31,107 @@ export class StartupService {
23
31
  await this.broadcastSelf();
24
32
  this.log.info(`Finished seeding from peer ${this.startingPeer}`);
25
33
  }
26
- async populatePeers() {
27
- this.log.info(`Populating peers from ${this.startingPeer}`);
28
- const peerList = await this.service.peerRepository.getPeerList();
29
- if (peerList.length > 0) {
30
- return;
34
+ /** GET a JSON document from the starting peer, failing on a non-2xx response. */
35
+ async getFromStartingPeer(path) {
36
+ const response = await fetch(`${this.startingPeer}/v1${path}`, { signal: AbortSignal.timeout(STARTUP_REQUEST_TIMEOUT_MS) });
37
+ if (!response.ok) {
38
+ throw new Error(`Starting peer ${this.startingPeer} responded ${response.status} to GET /v1${path}`);
31
39
  }
32
- const response = await fetch(`${this.startingPeer}/v1/peers`);
33
- const data = await response.json();
34
- if (!data.peers) {
35
- throw new Error('Invalid response from peer');
40
+ return await response.json();
41
+ }
42
+ /**
43
+ * Walks a paginated list on the starting peer, handing each page to `handlePage` before
44
+ * fetching the next, so the whole list is never held at once. Returns the item count.
45
+ */
46
+ async forEachPageFromStartingPeer(path, key, handlePage) {
47
+ let offset = 0;
48
+ let count = 0;
49
+ while (true) {
50
+ const data = await this.getFromStartingPeer(`${path}?offset=${offset}&limit=${STARTUP_PAGE_SIZE}`);
51
+ const items = data[key];
52
+ const nextOffset = data.nextOffset ?? null;
53
+ // The next offset must move forward, or a bad peer could keep startup looping forever.
54
+ if (!Array.isArray(items) || (nextOffset !== null && (!Number.isInteger(nextOffset) || nextOffset <= offset))) {
55
+ throw new Error('Invalid response from peer');
56
+ }
57
+ await handlePage(items);
58
+ count += items.length;
59
+ if (nextOffset === null) {
60
+ return count;
61
+ }
62
+ offset = nextOffset;
36
63
  }
37
- if (data.peers) {
38
- for (const peer of data.peers) {
64
+ }
65
+ isSelf(peer) {
66
+ return peer.publicKey === this.publicKey || peer.baseUrl === this.baseUrl;
67
+ }
68
+ async populatePeers() {
69
+ this.log.info(`Populating peers from ${this.startingPeer}`);
70
+ // Known peers are skipped, so this is safe to repeat on every restart, and a run cut off
71
+ // partway through the pages is completed on the next one.
72
+ let count = 0;
73
+ const savePeers = async (peers) => {
74
+ for (const peer of peers.filter(peer => !this.isSelf(peer))) {
39
75
  await this.service.peerRepository.create(peer);
76
+ count++;
40
77
  }
41
- }
78
+ };
42
79
  // Save the starting peer to the database.
43
- const startingPeerResponse = await fetch(`${this.startingPeer}/v1/self`);
44
- const startingPeerData = await startingPeerResponse.json();
45
- await this.service.peerRepository.create({
46
- baseUrl: startingPeerData.baseUrl,
47
- publicKey: startingPeerData.publicKey,
48
- name: startingPeerData.name,
49
- });
50
- this.log.info(`Populated ${data.peers.length + 1} peers`);
80
+ await savePeers([await this.getFromStartingPeer('/self')]);
81
+ await this.forEachPageFromStartingPeer('/peers', 'peers', savePeers);
82
+ this.log.info(`Populated ${count} peers`);
51
83
  }
52
84
  async populateResources() {
53
85
  this.log.info(`Populating resources from ${this.startingPeer}`);
54
- const response = await fetch(`${this.startingPeer}/v1/resources`);
55
- const data = await response.json();
56
- if (!data.resources) {
57
- throw new Error('Invalid response from peer');
58
- }
59
- if (data.resources) {
60
- for (const resource of data.resources) {
61
- await this.service.resourceRepository.create(resource);
86
+ // Known resources are skipped, so this is safe to repeat on every restart.
87
+ const count = await this.forEachPageFromStartingPeer('/resources', 'resources', async (resources) => {
88
+ for (const resource of resources) {
89
+ await this.service.resourceRepository.learn(resource);
90
+ }
91
+ });
92
+ this.log.info(`Learned up to ${count} resources`);
93
+ }
94
+ /** PUT to every peer, logging failures instead of throwing so one bad peer doesn't stop the rest. */
95
+ async putToPeers(peers, path, body, description) {
96
+ for (const peer of peers) {
97
+ try {
98
+ const response = await fetch(`${peer.baseUrl}/v1${path}`, {
99
+ method: 'PUT',
100
+ headers: {
101
+ 'Content-Type': 'application/json',
102
+ },
103
+ body: JSON.stringify(body),
104
+ signal: AbortSignal.timeout(STARTUP_REQUEST_TIMEOUT_MS),
105
+ });
106
+ if (!response.ok) {
107
+ this.log.warn(`Failed to broadcast ${description} to node ${peer.baseUrl}: ${response.status} ${await response.text()}`);
108
+ }
109
+ }
110
+ catch (error) {
111
+ this.log.warn(error, `Failed to broadcast ${description} to node ${peer.baseUrl}`);
62
112
  }
63
113
  }
64
- this.log.info(`Populated ${data.resources.length} resources`);
65
114
  }
66
115
  async broadcastSelf() {
67
116
  this.log.info(`Broadcasting self to peers`);
68
117
  // Let the peers know about this node.
69
- const peers = await this.service.peerRepository.getPeerList();
70
- // 10 minutes
71
- const peerSignatureExpiry = new Date(Date.now() + 1000 * 60 * 10).toISOString();
72
- const peerSignature = signData({
73
- baseUrl: this.baseUrl,
74
- name: this.baseUrl,
75
- expiry: peerSignatureExpiry,
76
- }, this.privateKey);
77
- for (const peer of peers) {
78
- const response = await fetch(`${peer.baseUrl}/v1/peers`, {
79
- method: 'PUT',
80
- headers: {
81
- 'Content-Type': 'application/json',
82
- },
83
- body: JSON.stringify({
84
- baseUrl: this.baseUrl,
85
- name: this.baseUrl,
86
- publicKey: this.publicKey,
87
- signature: peerSignature,
88
- expiry: peerSignatureExpiry,
89
- }),
90
- });
91
- if (!response.ok) {
92
- this.log.warn(`Failed to broadcast self to node ${peer.baseUrl}: ${response.statusText}`);
93
- }
94
- }
118
+ const peers = (await this.service.peerRepository.getPeerList()).filter(peer => !this.isSelf(peer));
119
+ const self = { baseUrl: this.baseUrl, name: this.baseUrl, publicKey: this.publicKey };
120
+ const peerSignatureExpiry = new Date(Date.now() + SIGNATURE_LIFETIME_MS).toISOString();
121
+ const peerSignature = signData(peerAnnouncementPayload(self, peerSignatureExpiry), this.privateKey);
122
+ await this.putToPeers(peers, '/peers', { ...self, signature: peerSignature, expiry: peerSignatureExpiry }, 'self');
95
123
  this.log.info(`Broadcasted self to ${peers.length} peers`);
96
124
  this.log.info(`Broadcasting resources to peers`);
97
125
  // Let the peers know about the resources available on this node.
98
126
  const resources = await this.service.resourceRepository.getInternalResources();
99
- // 10 minutes
100
- const resourceSignatureExpiry = new Date(Date.now() + 1000 * 60 * 10).toISOString();
101
- const resourceSignature = signData({
127
+ const resourceSignatureExpiry = new Date(Date.now() + SIGNATURE_LIFETIME_MS).toISOString();
128
+ const resourceSignature = signData(resourceAnnouncementPayload(resources, resourceSignatureExpiry), this.privateKey);
129
+ await this.putToPeers(peers, '/all-resources', {
130
+ publicKey: this.publicKey,
102
131
  resources,
103
132
  expiry: resourceSignatureExpiry,
104
- }, this.privateKey);
105
- for (const peer of peers) {
106
- const response = await fetch(`${peer.baseUrl}/v1/all-resources`, {
107
- method: 'PUT',
108
- headers: {
109
- 'Content-Type': 'application/json',
110
- },
111
- body: JSON.stringify({
112
- resources,
113
- expiry: resourceSignatureExpiry,
114
- signature: resourceSignature,
115
- }),
116
- });
117
- if (!response.ok) {
118
- this.log.warn(`Failed to broadcast resources to node ${peer.baseUrl}: ${response.statusText}`);
119
- }
120
- }
133
+ signature: resourceSignature,
134
+ }, 'resources');
121
135
  this.log.info(`Broadcasted ${resources.length} resources to ${peers.length} peers`);
122
136
  }
123
137
  }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=operatorClient.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"operatorClient.test.d.ts","sourceRoot":"","sources":["../../../src/tests/client/operatorClient.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,96 @@
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);
25
+ });
26
+ afterEach(() => {
27
+ vi.useRealTimers();
28
+ vi.unstubAllGlobals();
29
+ vi.restoreAllMocks();
30
+ });
31
+ test("retries 429 responses with exponential backoff, then succeeds", async () => {
32
+ fetchMock
33
+ .mockResolvedValueOnce(status(429))
34
+ .mockResolvedValueOnce(status(429))
35
+ .mockResolvedValueOnce(ok());
36
+ const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
37
+ await vi.advanceTimersByTimeAsync(0);
38
+ expect(fetchMock).toHaveBeenCalledTimes(1);
39
+ await vi.advanceTimersByTimeAsync(999);
40
+ expect(fetchMock).toHaveBeenCalledTimes(1);
41
+ await vi.advanceTimersByTimeAsync(1);
42
+ expect(fetchMock).toHaveBeenCalledTimes(2);
43
+ await vi.advanceTimersByTimeAsync(2000);
44
+ expect(fetchMock).toHaveBeenCalledTimes(3);
45
+ await pending;
46
+ expect(console.error).not.toHaveBeenCalled();
47
+ const bodies = fetchMock.mock.calls.map(([, init]) => init.body);
48
+ expect(new Set(bodies).size).toBe(1);
49
+ });
50
+ test("waits for Retry-After when it is longer than the backoff", async () => {
51
+ fetchMock.mockResolvedValueOnce(status(429, { "retry-after": "30" })).mockResolvedValueOnce(ok());
52
+ const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
53
+ await vi.advanceTimersByTimeAsync(29_999);
54
+ expect(fetchMock).toHaveBeenCalledTimes(1);
55
+ await vi.advanceTimersByTimeAsync(1);
56
+ expect(fetchMock).toHaveBeenCalledTimes(2);
57
+ await pending;
58
+ });
59
+ test.each([408, 500, 502, 503, 504])("retries %i responses", async (code) => {
60
+ fetchMock.mockResolvedValueOnce(status(code)).mockResolvedValueOnce(ok());
61
+ const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
62
+ await vi.runAllTimersAsync();
63
+ await pending;
64
+ expect(fetchMock).toHaveBeenCalledTimes(2);
65
+ expect(console.error).not.toHaveBeenCalled();
66
+ });
67
+ test.each([400, 401, 404, 409, 422])("does not retry %i responses", async (code) => {
68
+ fetchMock.mockResolvedValue(status(code));
69
+ await new OperatorClient("https://operator.example/v1").settlePayment(payment);
70
+ expect(fetchMock).toHaveBeenCalledTimes(1);
71
+ expect(console.error).toHaveBeenCalledOnce();
72
+ });
73
+ test.each(["ECONNREFUSED", "ECONNRESET"])("retries %s network errors", async (code) => {
74
+ fetchMock.mockRejectedValueOnce(networkError(code)).mockResolvedValueOnce(ok());
75
+ const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
76
+ await vi.runAllTimersAsync();
77
+ await pending;
78
+ expect(fetchMock).toHaveBeenCalledTimes(2);
79
+ });
80
+ test("rethrows the last network error once retries are exhausted", async () => {
81
+ fetchMock.mockRejectedValue(networkError("ECONNREFUSED"));
82
+ const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
83
+ const assertion = expect(pending).rejects.toThrow("fetch failed");
84
+ await vi.runAllTimersAsync();
85
+ await assertion;
86
+ expect(fetchMock).toHaveBeenCalledTimes(1 + SETTLE_PAYMENT_MAX_RETRIES);
87
+ });
88
+ test("gives up after the maximum number of retries", async () => {
89
+ fetchMock.mockResolvedValue(status(429));
90
+ const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
91
+ await vi.runAllTimersAsync();
92
+ await pending;
93
+ expect(fetchMock).toHaveBeenCalledTimes(1 + SETTLE_PAYMENT_MAX_RETRIES);
94
+ expect(console.error).toHaveBeenCalledOnce();
95
+ });
96
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=db.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"db.test.d.ts","sourceRoot":"","sources":["../../../src/tests/db/db.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,64 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { open } from "sqlite";
5
+ import sqlite3 from "sqlite3";
6
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
7
+ import { openDatabase } from "../../db/db.js";
8
+ describe("openDatabase", () => {
9
+ let dir;
10
+ beforeEach(async () => {
11
+ dir = await mkdtemp(path.join(tmpdir(), "stellarium-db-"));
12
+ });
13
+ afterEach(async () => {
14
+ await rm(dir, { recursive: true, force: true });
15
+ });
16
+ test("can be reopened without errors", async () => {
17
+ const filename = path.join(dir, "node.db");
18
+ await (await openDatabase(filename)).close();
19
+ const db = await openDatabase(filename);
20
+ expect(await db.get("PRAGMA user_version")).toEqual({ user_version: 2 });
21
+ await db.close();
22
+ });
23
+ test("rebuilds resource tables from the old schema and keeps peers", async () => {
24
+ const filename = path.join(dir, "old.db");
25
+ const old = await open({ filename, driver: sqlite3.Database });
26
+ await old.exec(`
27
+ CREATE TABLE peer (id INTEGER PRIMARY KEY AUTOINCREMENT, base_url TEXT NOT NULL, public_key TEXT NOT NULL, name TEXT NOT NULL,
28
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE (public_key), UNIQUE (base_url), UNIQUE (name));
29
+ CREATE TABLE resource (id INTEGER PRIMARY KEY AUTOINCREMENT, peer_id INTEGER, path TEXT NOT NULL, name TEXT NOT NULL,
30
+ base_url TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE (path, name), UNIQUE (base_url));
31
+ INSERT INTO peer (base_url, public_key, name, created_at, updated_at) VALUES ('http://p', 'key', 'p', 'now', 'now');
32
+ INSERT INTO resource (path, name, base_url, created_at, updated_at) VALUES ('com.p', 'A', 'http://p', 'now', 'now');
33
+ `);
34
+ await old.close();
35
+ const db = await openDatabase(filename);
36
+ expect(await db.all("SELECT public_key FROM peer")).toEqual([{ public_key: "key" }]);
37
+ expect(await db.all("SELECT * FROM resource")).toEqual([]);
38
+ // Two resources on one base URL failed under the old UNIQUE (base_url).
39
+ await db.exec(`
40
+ INSERT INTO resource (path, name, base_url, created_at, updated_at) VALUES ('com.p', 'A', 'http://p', 'now', 'now');
41
+ INSERT INTO resource (path, name, base_url, created_at, updated_at) VALUES ('com.p', 'B', 'http://p', 'now', 'now');
42
+ `);
43
+ await db.close();
44
+ });
45
+ test("adds the restricted column to a version 1 database and keeps held grants on later opens", async () => {
46
+ const filename = path.join(dir, "v1.db");
47
+ const old = await open({ filename, driver: sqlite3.Database });
48
+ await old.exec(`
49
+ CREATE TABLE resource_function (id INTEGER PRIMARY KEY AUTOINCREMENT, resource_id INTEGER NOT NULL, name TEXT NOT NULL,
50
+ return_type TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE (resource_id, name));
51
+ PRAGMA user_version = 1;
52
+ `);
53
+ await old.close();
54
+ const db = await openDatabase(filename);
55
+ const columns = await db.all("PRAGMA table_info(resource_function)");
56
+ expect(columns.map(column => column.name)).toContain("restricted");
57
+ await db.run("INSERT INTO held_grant (issuer, id, root_issuer_base_url, chain, issued_at, expires_at, updated_at) VALUES ('k', 'g', 'http://p', '[]', 'now', 'now', 'now')");
58
+ await db.close();
59
+ // Grants are not a cache of anything a peer resends, so a reopen must not drop them.
60
+ const reopened = await openDatabase(filename);
61
+ expect(await reopened.all("SELECT id FROM held_grant")).toEqual([{ id: "g" }]);
62
+ await reopened.close();
63
+ });
64
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=grant.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"grant.test.d.ts","sourceRoot":"","sources":["../../../src/tests/grant/grant.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,133 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { capabilityCovers, capabilityMatches, grantHash, grantPayload, MAX_GRANT_CHAIN_LENGTH, signGrant, verifyChain, } from "../../grant/grant.js";
3
+ import { verifyData } from "../../crypto/utils.js";
4
+ import { generateBase64KeyPair } from "../helpers.js";
5
+ const inHours = (hours) => new Date(Date.now() + hours * 3_600_000).toISOString();
6
+ const weather = { resourcePath: "com.example", resourceName: "Weather" };
7
+ // Shared, so a child computed a millisecond later doesn't outlive its parent.
8
+ const expiresAt = inHours(1);
9
+ function grantFrom(issuer, audience, capabilities, parent = null, overrides = {}) {
10
+ return signGrant({
11
+ id: crypto.randomUUID(),
12
+ issuer: issuer.publicKey,
13
+ issuerBaseUrl: "http://issuer",
14
+ audience: audience.publicKey,
15
+ capabilities,
16
+ issuedAt: new Date().toISOString(),
17
+ expiresAt,
18
+ parent: parent ? grantHash(parent) : null,
19
+ ...overrides,
20
+ }, issuer.privateKey);
21
+ }
22
+ describe("verifyChain", () => {
23
+ const provider = generateBase64KeyPair();
24
+ const gateway = generateBase64KeyPair();
25
+ const user = generateBase64KeyPair();
26
+ const other = generateBase64KeyPair();
27
+ test("accepts a root grant and returns it as the leaf", () => {
28
+ const root = grantFrom(provider, gateway, [weather]);
29
+ expect(verifyChain([root], provider.publicKey)).toEqual({ ok: true, leaf: root });
30
+ expect(verifyData(grantPayload(root), provider.publicKey, root.signature)).toBe(true);
31
+ });
32
+ test("accepts a three-grant chain that narrows at each link", () => {
33
+ const root = grantFrom(provider, gateway, [{ resourcePath: "com.*" }]);
34
+ const middle = grantFrom(gateway, user, [weather], root);
35
+ const leaf = grantFrom(user, other, [{ ...weather, functionName: "temperature", constraints: { city: "Oslo" } }], middle);
36
+ expect(verifyChain([root, middle, leaf], provider.publicKey)).toEqual({ ok: true, leaf });
37
+ });
38
+ test.each([
39
+ ["an empty chain", () => []],
40
+ ["a non-array", () => "nope"],
41
+ ["a malformed grant", () => [{ ...grantFrom(provider, gateway, [weather]), capabilities: [] }]],
42
+ ])("rejects %s", (_, chain) => {
43
+ expect(verifyChain(chain(), provider.publicKey).ok).toBe(false);
44
+ });
45
+ test("rejects a root issued by another key", () => {
46
+ expect(verifyChain([grantFrom(other, gateway, [weather])], provider.publicKey).ok).toBe(false);
47
+ });
48
+ test("rejects a root that claims a parent", () => {
49
+ const root = grantFrom(provider, gateway, [weather], null, { parent: "something" });
50
+ expect(verifyChain([root], provider.publicKey).ok).toBe(false);
51
+ });
52
+ test("rejects a tampered grant", () => {
53
+ const root = grantFrom(provider, gateway, [weather]);
54
+ expect(verifyChain([{ ...root, capabilities: [{ resourcePath: "*" }] }], provider.publicKey).ok).toBe(false);
55
+ });
56
+ test("rejects an expired grant and one that lives too long", () => {
57
+ expect(verifyChain([grantFrom(provider, gateway, [weather], null, { expiresAt: inHours(-1) })], provider.publicKey).ok).toBe(false);
58
+ expect(verifyChain([grantFrom(provider, gateway, [weather], null, { expiresAt: inHours(24 * 31) })], provider.publicKey).ok).toBe(false);
59
+ });
60
+ test("rejects a link signed by someone other than the parent's audience", () => {
61
+ const root = grantFrom(provider, gateway, [weather]);
62
+ expect(verifyChain([root, grantFrom(other, user, [weather], root)], provider.publicKey).ok).toBe(false);
63
+ });
64
+ test("rejects a link naming a different parent", () => {
65
+ const root = grantFrom(provider, gateway, [weather]);
66
+ const sibling = grantFrom(provider, gateway, [weather]);
67
+ expect(verifyChain([root, grantFrom(gateway, user, [weather], sibling)], provider.publicKey).ok).toBe(false);
68
+ });
69
+ test("rejects a link that outlives its parent", () => {
70
+ const root = grantFrom(provider, gateway, [weather]);
71
+ expect(verifyChain([root, grantFrom(gateway, user, [weather], root, { expiresAt: inHours(2) })], provider.publicKey).ok).toBe(false);
72
+ });
73
+ test.each([
74
+ ["a wider path", { resourcePath: "com.*" }],
75
+ ["another resource", { resourcePath: "com.example", resourceName: "Traffic" }],
76
+ ])("rejects a link that widens to %s", (_, capability) => {
77
+ const root = grantFrom(provider, gateway, [weather]);
78
+ expect(verifyChain([root, grantFrom(gateway, user, [capability], root)], provider.publicKey).ok).toBe(false);
79
+ });
80
+ test("rejects a link that drops a constraint", () => {
81
+ const root = grantFrom(provider, gateway, [{ ...weather, constraints: { city: "Oslo" } }]);
82
+ expect(verifyChain([root, grantFrom(gateway, user, [weather], root)], provider.publicKey).ok).toBe(false);
83
+ });
84
+ test("rejects a chain longer than the limit", () => {
85
+ const keys = Array.from({ length: MAX_GRANT_CHAIN_LENGTH + 1 }, () => generateBase64KeyPair());
86
+ const chain = [grantFrom(provider, keys[0], [weather])];
87
+ for (let i = 1; i <= MAX_GRANT_CHAIN_LENGTH; i++) {
88
+ chain.push(grantFrom(keys[i - 1], keys[i], [weather], chain[i - 1]));
89
+ }
90
+ expect(verifyChain(chain.slice(0, MAX_GRANT_CHAIN_LENGTH), provider.publicKey).ok).toBe(true);
91
+ expect(verifyChain(chain, provider.publicKey).ok).toBe(false);
92
+ });
93
+ });
94
+ describe("capabilityMatches", () => {
95
+ const oslo = [{ name: "city", value: "Oslo" }];
96
+ test.each([
97
+ [{ resourcePath: "com.example" }, "com.example", true],
98
+ [{ resourcePath: "com.example.*" }, "com.example", true],
99
+ [{ resourcePath: "com.example.*" }, "com.example.sub", true],
100
+ [{ resourcePath: "com.example.*" }, "com.examplefoo", false],
101
+ [{ resourcePath: "com.*" }, "com.example", true],
102
+ [{ resourcePath: "*" }, "org.anything", true],
103
+ [{ resourcePath: "com.example" }, "com.example.sub", false],
104
+ ])("%o on path %s is %s", (capability, path, expected) => {
105
+ expect(capabilityMatches(capability, path, "Weather", "temperature", oslo)).toBe(expected);
106
+ });
107
+ test("checks names and constraints", () => {
108
+ expect(capabilityMatches({ ...weather, functionName: "temperature" }, "com.example", "Weather", "humidity", oslo)).toBe(false);
109
+ expect(capabilityMatches({ ...weather, constraints: { city: "Oslo" } }, "com.example", "Weather", "temperature", oslo)).toBe(true);
110
+ expect(capabilityMatches({ ...weather, constraints: { city: "Paris" } }, "com.example", "Weather", "temperature", oslo)).toBe(false);
111
+ expect(capabilityMatches({ ...weather, constraints: { units: "C" } }, "com.example", "Weather", "temperature", oslo)).toBe(false);
112
+ });
113
+ test("refuses a constrained parameter that appears more than once", () => {
114
+ const duplicated = [...oslo, { name: "city", value: "Paris" }];
115
+ expect(capabilityMatches({ ...weather, constraints: { city: "Oslo" } }, "com.example", "Weather", "temperature", duplicated)).toBe(false);
116
+ });
117
+ });
118
+ describe("capabilityCovers", () => {
119
+ test.each([
120
+ [{ resourcePath: "*" }, { resourcePath: "*" }, true],
121
+ [{ resourcePath: "*" }, { resourcePath: "com.example" }, true],
122
+ [{ resourcePath: "com.*" }, { resourcePath: "com.example.*" }, true],
123
+ [{ resourcePath: "com.*" }, { resourcePath: "*" }, false],
124
+ [{ resourcePath: "com.example.*" }, { resourcePath: "com.examplefoo" }, false],
125
+ [{ resourcePath: "com.example" }, { resourcePath: "com.example.*" }, false],
126
+ [weather, { ...weather, functionName: "temperature" }, true],
127
+ [{ ...weather, functionName: "temperature" }, weather, false],
128
+ [{ ...weather, constraints: { city: "Oslo" } }, { ...weather, constraints: { city: "Oslo", units: "C" } }, true],
129
+ [{ ...weather, constraints: { city: "Oslo" } }, { ...weather, constraints: { city: "Paris" } }, false],
130
+ ])("%o covers %o: %s", (parent, child, expected) => {
131
+ expect(capabilityCovers(parent, child)).toBe(expected);
132
+ });
133
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=service.test.d.ts.map