stellarium-ts 0.11.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 +303 -49
  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/routes.js CHANGED
@@ -1,12 +1,148 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
+ import rateLimit from '@fastify/rate-limit';
1
3
  import { interpretParsed, parseScript } from 'requestscript';
2
- import { paymentVoucherPayload } from './resource/invoker.js';
3
- import { verifyData } from './crypto/utils.js';
4
+ import { invocationHash, paymentVoucherPayload } from './resource/invoker.js';
5
+ import { safeVerifyData } from './crypto/utils.js';
6
+ import { InvalidPublicKeyError } from './grant/service.js';
7
+ // Body schemas check only the shape the handlers rely on. Signed payloads (resources) are
8
+ // left unvalidated inside, since coercing or stripping them would change the signed bytes.
9
+ const nonEmptyString = { type: 'string', minLength: 1 };
10
+ const peerRequestSchema = {
11
+ type: 'object',
12
+ required: ['baseUrl', 'name', 'publicKey', 'signature', 'expiry'],
13
+ properties: { baseUrl: nonEmptyString, name: nonEmptyString, publicKey: nonEmptyString, signature: nonEmptyString, expiry: nonEmptyString },
14
+ };
15
+ const resourceRequestSchema = {
16
+ type: 'object',
17
+ required: ['publicKey', 'signature', 'expiry', 'resources'],
18
+ properties: { publicKey: nonEmptyString, signature: nonEmptyString, expiry: nonEmptyString, resources: { type: 'array' } },
19
+ };
20
+ export const DEFAULT_PAGE_SIZE = 100;
21
+ export const MAX_PAGE_SIZE = 500;
22
+ const pageQuerySchema = {
23
+ type: 'object',
24
+ properties: {
25
+ offset: { type: 'integer', minimum: 0, default: 0 },
26
+ limit: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE, default: DEFAULT_PAGE_SIZE },
27
+ },
28
+ };
29
+ const revokeQuerySchema = {
30
+ type: 'object',
31
+ properties: {
32
+ issuer: { type: 'string', minLength: 1 },
33
+ },
34
+ };
35
+ /**
36
+ * Fetches one extra item to tell whether another page follows. `nextOffset` is null on the last page.
37
+ */
38
+ async function getPage(query, fetchPage) {
39
+ const { offset, limit } = query;
40
+ const items = await fetchPage(offset, limit + 1);
41
+ return {
42
+ items: items.slice(0, limit),
43
+ nextOffset: items.length > limit ? offset + limit : null,
44
+ };
45
+ }
46
+ const runRequestSchema = {
47
+ type: 'object',
48
+ required: ['source'],
49
+ properties: { source: { type: 'string' } },
50
+ };
51
+ const resourceRunRequestSchema = {
52
+ type: 'object',
53
+ required: ['resourcePath', 'resourceName', 'functionName', 'parameters'],
54
+ properties: {
55
+ resourcePath: { type: 'string' },
56
+ resourceName: { type: 'string' },
57
+ functionName: { type: 'string' },
58
+ parameters: {
59
+ type: 'array',
60
+ items: { type: 'object', required: ['name'], properties: { name: { type: 'string' } } },
61
+ },
62
+ paymentVoucher: {
63
+ type: 'object',
64
+ required: ['requestId', 'invocationId', 'amount', 'recipientAddress', 'signature', 'expiresAt', 'invocationHash', 'publicKey'],
65
+ properties: {
66
+ requestId: { type: 'string' },
67
+ invocationId: { type: 'string' },
68
+ amount: { type: 'integer' },
69
+ recipientAddress: { type: 'string' },
70
+ signature: { type: 'string' },
71
+ expiresAt: { type: 'string' },
72
+ invocationHash: { type: 'string' },
73
+ publicKey: { type: 'string' },
74
+ },
75
+ },
76
+ // Shape only: the grants inside are signed, so coercing them would change the signed bytes.
77
+ authorization: { type: 'object' },
78
+ },
79
+ };
80
+ const grantRenewalRequestSchema = {
81
+ type: 'object',
82
+ required: ['grantId', 'publicKey', 'nonce', 'expiresAt', 'signature'],
83
+ properties: { grantId: nonEmptyString, publicKey: nonEmptyString, nonce: nonEmptyString, expiresAt: nonEmptyString, signature: nonEmptyString },
84
+ };
85
+ // Capabilities and details are signed, so only their outer types are checked here.
86
+ const grantRequestSchema = {
87
+ type: 'object',
88
+ required: ['publicKey', 'capabilities', 'nonce', 'expiresAt', 'signature'],
89
+ properties: {
90
+ publicKey: nonEmptyString,
91
+ capabilities: { type: 'array', minItems: 1 },
92
+ details: { type: ['object', 'null'] },
93
+ nonce: nonEmptyString,
94
+ expiresAt: nonEmptyString,
95
+ signature: nonEmptyString,
96
+ },
97
+ };
98
+ /** Constant-time comparison. Hashing first gives equal-length buffers for timingSafeEqual. */
99
+ function secretsEqual(a, b) {
100
+ const digest = (value) => createHash('sha256').update(value).digest();
101
+ return timingSafeEqual(digest(a), digest(b));
102
+ }
103
+ /**
104
+ * A voucher is valid when it is bound to this exact call, signed by its public key, unexpired,
105
+ * and pays at least the function's cost to the function's recipient.
106
+ */
107
+ function isValidPaymentVoucher(voucher, manifest, request) {
108
+ const expectedHash = invocationHash(request.resourcePath, request.resourceName, request.functionName, request.parameters);
109
+ if (voucher.invocationHash !== expectedHash) {
110
+ return false;
111
+ }
112
+ if (voucher.amount < manifest.costLamports) {
113
+ return false;
114
+ }
115
+ if (voucher.recipientAddress !== manifest.recipientAddress) {
116
+ return false;
117
+ }
118
+ const expiresAt = Date.parse(voucher.expiresAt);
119
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
120
+ return false;
121
+ }
122
+ return safeVerifyData(paymentVoucherPayload(voucher), voucher.publicKey, voucher.signature);
123
+ }
4
124
  /**
5
125
  * Encapsulates the routes
6
126
  * @param fastify Encapsulated Fastify Instance
7
127
  * @param options plugin options, refer to https://fastify.dev/docs/latest/Reference/Plugins/#plugin-options
8
128
  */
9
129
  const routes = async (fastify, options) => {
130
+ // Rate limiting is opt-in per route via `config.rateLimit`.
131
+ await fastify.register(rateLimit, { global: false });
132
+ /** Requires `Authorization: Bearer <API_KEY>`. Used by the routes only this node's operator may call. */
133
+ const requireApiKey = async (request, reply) => {
134
+ // Check that the API Key matches the configured key
135
+ const authorizationHeader = request.headers['authorization'];
136
+ if (!authorizationHeader) {
137
+ return reply.status(401).send({ error: 'Authorization header is required' });
138
+ }
139
+ const apiKey = authorizationHeader.split(' ')[1];
140
+ // An unset API key must not match a header with no token.
141
+ const expectedApiKey = options.service.config.apiKey;
142
+ if (!expectedApiKey || !apiKey || !secretsEqual(apiKey, expectedApiKey)) {
143
+ return reply.status(401).send({ error: 'Invalid API Key' });
144
+ }
145
+ };
10
146
  // Start Meta Routes
11
147
  fastify.get('/', async (request, reply) => {
12
148
  return {
@@ -14,27 +150,28 @@ const routes = async (fastify, options) => {
14
150
  };
15
151
  });
16
152
  fastify.get('/self', async (request, reply) => {
17
- return {
18
- baseUrl: process.env.BASE_URL,
19
- publicKey: process.env.SIGNING_PUBLIC_KEY,
20
- name: process.env.BASE_URL,
21
- };
153
+ const { baseUrl, publicKey } = options.service.config;
154
+ return { baseUrl, publicKey, name: baseUrl };
22
155
  });
23
- fastify.get('/peers', async (request, reply) => {
24
- const peers = await options.service.peerRepository.getPeerList();
25
- return { peers };
156
+ fastify.get('/peers', { schema: { querystring: pageQuerySchema } }, async (request, reply) => {
157
+ const { items: peers, nextOffset } = await getPage(request.query, (offset, limit) => options.service.peerRepository.getPeers(offset, limit));
158
+ return { peers, nextOffset };
26
159
  });
27
- fastify.put('/peers', async (request, reply) => {
28
- const peer = request.body;
29
- await options.service.peerRepository.createIfNotExists(peer, peer.signature, peer.expiry);
160
+ fastify.put('/peers', { schema: { body: peerRequestSchema } }, async (request, reply) => {
161
+ const { baseUrl, name, publicKey, signature, expiry } = request.body;
162
+ const result = await options.service.peerRepository.announce({ baseUrl, name, publicKey }, signature, expiry);
163
+ if (!result.ok) {
164
+ return result.error === 'conflict'
165
+ ? reply.status(409).send({ error: 'Base URL or name belongs to another peer' })
166
+ : reply.status(400).send({ error: 'Invalid signature' });
167
+ }
30
168
  return { success: true };
31
169
  });
32
170
  /**
33
- * Returns the list of resources that are available on the server.
34
- * Does not return resources that are external to the server.
171
+ * Returns one page of the resources hosted on this node, followed by the resources it learned from peers.
35
172
  */
36
- fastify.get('/resources', async (request, reply) => {
37
- const resources = await options.service.resourceRepository.getAllResources();
173
+ fastify.get('/resources', { schema: { querystring: pageQuerySchema } }, async (request, reply) => {
174
+ const { items: resources, nextOffset } = await getPage(request.query, (offset, limit) => options.service.resourceRepository.getResources(offset, limit));
38
175
  return {
39
176
  resources: resources.map((resource) => ({
40
177
  path: resource.path,
@@ -47,42 +184,44 @@ const routes = async (fastify, options) => {
47
184
  costLamports: fn.paymentManifest.costLamports,
48
185
  recipientAddress: fn.paymentManifest.recipientAddress,
49
186
  }) : undefined,
187
+ restricted: fn.restricted || undefined,
50
188
  })),
51
189
  baseUrl: resource.baseUrl,
52
190
  })),
191
+ nextOffset,
53
192
  };
54
193
  });
55
- fastify.put('/all-resources', async (request, reply) => {
194
+ fastify.put('/all-resources', { schema: { body: resourceRequestSchema } }, async (request, reply) => {
56
195
  const resourceRequest = request.body;
57
196
  const resources = resourceRequest.resources;
58
197
  const existingPeer = await options.service.peerRepository.getPeerByPublicKey(resourceRequest.publicKey);
59
198
  if (!existingPeer) {
60
199
  return reply.status(400).send({ error: 'Peer not found' });
61
200
  }
62
- const success = await options.service.resourceRepository.saveAll(resources, existingPeer, resourceRequest.signature, resourceRequest.expiry);
63
- if (!success) {
201
+ const result = await options.service.resourceRepository.saveAll(resources, existingPeer, resourceRequest.signature, resourceRequest.expiry);
202
+ if (!result.ok) {
64
203
  return reply.status(400).send({ error: 'Invalid signature' });
65
204
  }
66
- return { success: true };
205
+ // Resources this peer may not publish (not served from its base URL, or owned elsewhere).
206
+ return { success: true, rejected: result.rejected };
67
207
  });
68
208
  // End Meta Routes
69
209
  // Start Run Routes
70
- fastify.post('/run', async (request, reply) => {
210
+ // Authenticate before body validation, so unauthenticated callers learn nothing about the schema.
211
+ fastify.post('/run', { schema: { body: runRequestSchema }, preValidation: requireApiKey }, async (request, reply) => {
71
212
  const runRequest = request.body;
72
- // Check that the API Key matches the env variable
73
- const authorizationHeader = request.headers['authorization'];
74
- if (!authorizationHeader) {
75
- return reply.status(401).send({ error: 'Authorization header is required' });
213
+ let script;
214
+ try {
215
+ script = parseScript(runRequest.source);
76
216
  }
77
- const apiKey = authorizationHeader.split(' ')[1];
78
- if (apiKey !== process.env.API_KEY) {
79
- return reply.status(401).send({ error: 'Invalid API Key' });
217
+ catch (error) {
218
+ // A script that doesn't parse is the caller's mistake, not a server failure.
219
+ return reply.status(400).send({ error: 'Invalid script', details: error instanceof Error ? error.message : undefined });
220
+ }
221
+ if (script.declaration.kind !== 'request') {
222
+ return reply.status(400).send({ error: 'Invalid script' });
80
223
  }
81
224
  try {
82
- const script = parseScript(runRequest.source);
83
- if (script.declaration.kind !== 'request') {
84
- return reply.status(400).send({ error: 'Invalid script' });
85
- }
86
225
  const result = await interpretParsed(script, {
87
226
  resourceResolver: options.service.resourceResolver,
88
227
  resourceInvoker: options.service.resourceInvoker
@@ -94,34 +233,149 @@ const routes = async (fastify, options) => {
94
233
  reply.status(500).send({ error: 'Internal server error' });
95
234
  }
96
235
  });
97
- fastify.post('/run/resource', async (request, reply) => {
236
+ fastify.post('/run/resource', {
237
+ schema: { body: resourceRunRequestSchema },
238
+ config: { rateLimit: options.rateLimit ?? { max: 100, timeWindow: '1 minute' } },
239
+ }, async (request, reply) => {
98
240
  const resourceRequest = request.body;
99
241
  const paymentVoucher = resourceRequest.paymentVoucher;
100
- if (paymentVoucher) {
101
- const isValid = verifyData(paymentVoucherPayload(paymentVoucher, resourceRequest.resourcePath, resourceRequest.resourceName, resourceRequest.parameters), paymentVoucher.publicKey, paymentVoucher.signature);
102
- if (!isValid) {
103
- return reply.status(400).send({ error: 'Invalid payment voucher' });
104
- }
105
- }
106
242
  const resource = await options.service.resourceRepository.getInternalResource(resourceRequest.resourcePath, resourceRequest.resourceName);
107
243
  if (!resource) {
108
244
  return reply.status(400).send({ error: 'Resource not found' });
109
245
  }
246
+ const resourceFunction = resource.functions.find(fn => fn.name === resourceRequest.functionName);
247
+ if (!resourceFunction) {
248
+ return reply.status(400).send({ error: 'Function not found' });
249
+ }
250
+ // Grant constraints and the function both read parameters by name, so each name must be
251
+ // declared and appear once. Otherwise a duplicate or extra parameter could pass a
252
+ // constraint check with one value and reach the function with another.
253
+ const declared = new Set(resourceFunction.parameters.map(parameter => parameter.name));
254
+ const names = resourceRequest.parameters.map(parameter => parameter.name);
255
+ if (new Set(names).size !== names.length || !names.every(name => declared.has(name))) {
256
+ return reply.status(400).send({ error: 'Parameters must be declared by the function and appear once each' });
257
+ }
258
+ // A restricted function is never run without a grant from this node. Checked before the
259
+ // voucher, so an unauthorized caller never gets near settlement.
260
+ if (resourceFunction.restricted) {
261
+ // Tells a caller without a usable grant where it can ask for one.
262
+ const grantEndpoint = options.service.grantService.acceptsRequests ? `${options.service.config.baseUrl}/v1/grants/request` : undefined;
263
+ if (!resourceRequest.authorization) {
264
+ return reply.status(403).send({ error: 'Authorization is required', grantEndpoint });
265
+ }
266
+ if (!await options.service.grantService.verifyInvocation(resourceRequest.authorization, resourceRequest)) {
267
+ return reply.status(403).send({ error: 'Invalid authorization', grantEndpoint });
268
+ }
269
+ }
270
+ // Payment manifests are per function. A paid function is never run without a valid voucher.
271
+ const paymentManifest = resourceFunction.paymentManifest;
272
+ if (paymentManifest) {
273
+ if (!paymentVoucher) {
274
+ return reply.status(402).send({ error: 'Payment voucher is required' });
275
+ }
276
+ if (!isValidPaymentVoucher(paymentVoucher, paymentManifest, resourceRequest)) {
277
+ return reply.status(400).send({ error: 'Invalid payment voucher' });
278
+ }
279
+ if (await options.service.grantService.isBlocked(paymentVoucher.publicKey)) {
280
+ return reply.status(403).send({ error: 'Caller is blocked' });
281
+ }
282
+ }
110
283
  const result = await options.service.resourceInvoker.invoke(resource, resourceRequest.functionName, resourceRequest.parameters);
111
- // A voucher is optional. Settlement runs only after a successful invoke. Run this on a separate thread to avoid blocking the main thread.
112
- if (paymentVoucher) {
284
+ // Settle only verified vouchers, and only after a successful invoke. A voucher sent to a free function is ignored.
285
+ // Run this on a separate thread to avoid blocking the main thread.
286
+ if (paymentManifest && paymentVoucher && !result.error) {
113
287
  setTimeout(async () => {
114
- await options.service.operatorClient.settlePayment({
115
- voucher: paymentVoucher,
116
- resourcePath: resourceRequest.resourcePath,
117
- resourceName: resourceRequest.resourceName,
118
- parameters: resourceRequest.parameters,
119
- });
288
+ // Nothing awaits this callback, so an uncaught error here would be an unhandled rejection.
289
+ try {
290
+ await options.service.operatorClient.settlePayment({ voucher: paymentVoucher });
291
+ }
292
+ catch (error) {
293
+ fastify.log.error(error, 'Failed to settle payment');
294
+ }
120
295
  }, 0);
121
296
  }
122
- return { returnValue: result };
297
+ return result;
123
298
  });
124
299
  // End Run Routes
300
+ // Start Grant Routes
301
+ /** Issues a root grant to the request's signer, if the node's approval hook allows it. */
302
+ fastify.post('/grants/request', {
303
+ schema: { body: grantRequestSchema },
304
+ config: { rateLimit: options.rateLimit ?? { max: 100, timeWindow: '1 minute' } },
305
+ }, async (request, reply) => {
306
+ if (!options.service.grantService.acceptsRequests) {
307
+ return reply.status(403).send({ error: 'This node does not issue grants on request' });
308
+ }
309
+ let grants;
310
+ try {
311
+ grants = await options.service.grantService.handleRequest(request.body);
312
+ }
313
+ catch (error) {
314
+ // An approval hook that throws or returns an invalid approval is this node's fault, not the requester's.
315
+ fastify.log.error(error, 'Failed to decide grant request');
316
+ return reply.status(500).send({ error: 'Internal server error' });
317
+ }
318
+ if (!grants) {
319
+ return reply.status(403).send({ error: 'Grant request refused' });
320
+ }
321
+ return { grants };
322
+ });
323
+ /** Re-issues a grant this node issued to the grant's audience, with a new expiry. */
324
+ fastify.post('/grants/renew', {
325
+ schema: { body: grantRenewalRequestSchema },
326
+ config: { rateLimit: options.rateLimit ?? { max: 100, timeWindow: '1 minute' } },
327
+ }, async (request, reply) => {
328
+ const grants = await options.service.grantService.renew(request.body);
329
+ if (!grants) {
330
+ return reply.status(403).send({ error: 'Grant renewal refused' });
331
+ }
332
+ return { grants };
333
+ });
334
+ // End Grant Routes
335
+ // Start Admin Routes
336
+ // Authenticated before validation, like /run, so unauthenticated callers learn nothing about the schema.
337
+ /**
338
+ * Revokes a grant: this node refuses it in any chain, with everything delegated from it, and
339
+ * stops renewing it. Without `?issuer=`, the grant is one this node issued. With it, the grant
340
+ * may be one delegated further down a chain, so it can be cut off too.
341
+ */
342
+ fastify.put('/admin/revoked-grants/:grantId', { preValidation: requireApiKey, schema: { querystring: revokeQuerySchema } }, async (request) => {
343
+ const { grantId } = request.params;
344
+ const { issuer } = request.query;
345
+ await options.service.grantService.revoke(issuer === undefined ? grantId : { issuer, id: grantId });
346
+ return { success: true };
347
+ });
348
+ fastify.get('/admin/blocked-callers', { preValidation: requireApiKey, schema: { querystring: pageQuerySchema } }, async (request) => {
349
+ const { items: callers, nextOffset } = await getPage(request.query, (offset, limit) => options.service.grantService.getBlocked(offset, limit));
350
+ return { callers, nextOffset };
351
+ });
352
+ /** Blocks a caller key, with an optional `{ "reason" }` body. Unrestricted free functions stay open to it, since their callers are anonymous. */
353
+ fastify.put('/admin/blocked-callers/:publicKey', { preValidation: requireApiKey }, async (request, reply) => {
354
+ const { publicKey } = request.params;
355
+ // The body is optional, so it is checked here rather than by a schema that would require one.
356
+ const reason = request.body?.reason;
357
+ if (reason !== undefined && typeof reason !== 'string') {
358
+ return reply.status(400).send({ error: 'reason must be a string' });
359
+ }
360
+ try {
361
+ await options.service.grantService.block(publicKey, reason);
362
+ }
363
+ catch (error) {
364
+ if (error instanceof InvalidPublicKeyError) {
365
+ return reply.status(400).send({ error: error.message });
366
+ }
367
+ throw error;
368
+ }
369
+ return { success: true };
370
+ });
371
+ fastify.delete('/admin/blocked-callers/:publicKey', { preValidation: requireApiKey }, async (request, reply) => {
372
+ const { publicKey } = request.params;
373
+ if (!await options.service.grantService.unblock(publicKey)) {
374
+ return reply.status(404).send({ error: 'Caller is not blocked' });
375
+ }
376
+ return { success: true };
377
+ });
378
+ // End Admin Routes
125
379
  };
126
380
  // ESM
127
381
  export default routes;
package/dist/server.d.ts CHANGED
@@ -1,14 +1,76 @@
1
1
  import 'dotenv/config';
2
2
  import { FastifyInstance } from 'fastify';
3
+ import type { Capability, Grant } from './grant/grant.js';
4
+ import type { GrantKey } from './grant/repository.js';
5
+ import type { ApproveGrant, GrantOptions } from './grant/service.js';
3
6
  import { StellariumResource } from './resource/repository.js';
7
+ import { RateLimitConfig } from './routes.js';
8
+ import { type NodeConfig } from './service.js';
9
+ export declare const DEFAULT_COST_LIMIT_LAMPORTS = 10000;
10
+ export declare const DEFAULT_GRANT_REFRESH_INTERVAL_MS: number;
4
11
  export interface StellariumNodeOptions {
5
12
  port?: number;
6
13
  host?: string;
7
14
  startingPeer?: string;
15
+ /** Per-IP rate limit for `POST /v1/run/resource`. Defaults to 100 requests per minute. */
16
+ rateLimit?: RateLimitConfig;
17
+ /** URL peers use to reach this node. Defaults to `BASE_URL`. */
18
+ baseUrl?: string;
19
+ /** Defaults to `SIGNING_PUBLIC_KEY`. */
20
+ publicKey?: string;
21
+ /** Defaults to `SIGNING_PRIVATE_KEY`. */
22
+ privateKey?: string;
23
+ /** Bearer token for `POST /v1/run`. Defaults to `API_KEY`. */
24
+ apiKey?: string;
25
+ /** Defaults to `OPERATOR_URL`. */
26
+ operatorUrl?: string;
27
+ /** Most this node pays for one remote call. Defaults to `COST_LIMIT_LAMPORTS`, then 10,000. */
28
+ costLimitLamports?: number;
29
+ /** SQLite file. Defaults to `./requestscript.db`; use ':memory:' for tests. */
30
+ databasePath?: string;
31
+ /**
32
+ * Grant chains addressed to this node, stored at start. Each is root first. A stored renewal
33
+ * of the same grant is kept, and an expired chain is skipped with a warning.
34
+ */
35
+ grants?: Grant[][];
36
+ /** How often held grants past half their lifetime are renewed. Defaults to 5 minutes. */
37
+ grantRefreshIntervalMs?: number;
38
+ /**
39
+ * Decides callers' requests for grants on this node's resources (`POST /v1/grants/request`).
40
+ * Without it, callers can only get grants from `issueGrant`.
41
+ */
42
+ approveGrant?: ApproveGrant;
8
43
  }
44
+ export declare function resolveConfig(options: StellariumNodeOptions, resources?: StellariumResource[]): NodeConfig;
9
45
  export declare class StellariumNode {
10
46
  private resources;
47
+ private service?;
11
48
  register(resource: StellariumResource): Promise<void>;
49
+ /**
50
+ * Listens, then bootstraps from `startingPeer` if one is given.
51
+ * Rejects if the server can't listen or bootstrapping fails.
52
+ */
12
53
  start(fastify: FastifyInstance, options: StellariumNodeOptions): Promise<void>;
54
+ /** Issues a root grant for this node's resources. Hand the returned chain to the audience. */
55
+ issueGrant(audience: string, capabilities: Capability[], options: GrantOptions): Promise<Grant[]>;
56
+ /**
57
+ * Narrows a grant this node holds for another audience. Hand the returned chain to the audience.
58
+ * Name the held grant by its id, or by its issuer and id when two issuers used the same id.
59
+ */
60
+ delegateGrant(heldGrant: string | GrantKey, audience: string, capabilities: Capability[], options: GrantOptions): Promise<Grant[]>;
61
+ /** Asks another node for a grant on its resources, and stores it. Throws if refused. */
62
+ requestGrant(providerBaseUrl: string, capabilities: Capability[], details?: Record<string, unknown>): Promise<Grant[]>;
63
+ /** Stores a grant chain addressed to this node. */
64
+ addGrant(chain: Grant[]): Promise<void>;
65
+ /**
66
+ * Refuses the grant, and every grant delegated from it, in calls to this node, and stops its renewal.
67
+ * Name a grant this node issued by its id, and any other grant by its issuer and id.
68
+ */
69
+ revokeGrant(grant: string | GrantKey): Promise<void>;
70
+ /** Refuses this caller key in restricted calls, renewals, grant requests and paid calls. */
71
+ blockCaller(publicKey: string, reason?: string): Promise<void>;
72
+ /** False when the key wasn't blocked. */
73
+ unblockCaller(publicKey: string): Promise<boolean>;
74
+ private started;
13
75
  }
14
76
  //# sourceMappingURL=server.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,eAAe,CAAC;AACvB,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAE1C,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAK9D,MAAM,WAAW,qBAAqB;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,qBAAa,cAAc;IACvB,OAAO,CAAC,SAAS,CAA4B;IAEvC,QAAQ,CAAC,QAAQ,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAE1D;IAEK,KAAK,CAAC,OAAO,EAAE,eAAe,EAAE,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAgCnF;CACJ"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
package/dist/server.js CHANGED
@@ -1,32 +1,133 @@
1
1
  import 'dotenv/config';
2
- import { createTables } from './db/db.js';
2
+ import { DEFAULT_DATABASE_PATH, openDatabase } from './db/db.js';
3
3
  import routes from './routes.js';
4
4
  import { createService } from './service.js';
5
5
  import { StartupService } from './startup.js';
6
+ export const DEFAULT_COST_LIMIT_LAMPORTS = 10_000;
7
+ export const DEFAULT_GRANT_REFRESH_INTERVAL_MS = 1000 * 60 * 5;
8
+ function required(name, value) {
9
+ if (!value) {
10
+ throw new Error(`${name} is required: pass it to StellariumNode.start or set it in the environment`);
11
+ }
12
+ return value;
13
+ }
14
+ function hostsPaidFunctions(resources) {
15
+ return resources.some(resource => resource.functions.some(fn => fn.paymentManifest));
16
+ }
17
+ export function resolveConfig(options, resources = []) {
18
+ const costLimit = options.costLimitLamports ?? (process.env.COST_LIMIT_LAMPORTS ? Number(process.env.COST_LIMIT_LAMPORTS) : DEFAULT_COST_LIMIT_LAMPORTS);
19
+ if (!Number.isSafeInteger(costLimit) || costLimit < 0) {
20
+ throw new Error('costLimitLamports must be a nonnegative integer');
21
+ }
22
+ return {
23
+ baseUrl: required('BASE_URL', options.baseUrl ?? process.env.BASE_URL),
24
+ publicKey: required('SIGNING_PUBLIC_KEY', options.publicKey ?? process.env.SIGNING_PUBLIC_KEY),
25
+ privateKey: required('SIGNING_PRIVATE_KEY', options.privateKey ?? process.env.SIGNING_PRIVATE_KEY),
26
+ apiKey: options.apiKey ?? process.env.API_KEY,
27
+ // Only a node hosting paid functions settles payments.
28
+ operatorUrl: hostsPaidFunctions(resources)
29
+ ? required('OPERATOR_URL', options.operatorUrl ?? process.env.OPERATOR_URL)
30
+ : options.operatorUrl ?? process.env.OPERATOR_URL,
31
+ costLimitLamports: costLimit,
32
+ };
33
+ }
6
34
  export class StellariumNode {
7
35
  resources = [];
36
+ service;
8
37
  async register(resource) {
9
38
  this.resources.push(resource);
10
39
  }
40
+ /**
41
+ * Listens, then bootstraps from `startingPeer` if one is given.
42
+ * Rejects if the server can't listen or bootstrapping fails.
43
+ */
11
44
  async start(fastify, options) {
12
- ;
13
- const service = createService(this.resources);
14
- await createTables();
15
- fastify.register(routes, { prefix: '/v1', service });
16
- // Run the server!
17
- fastify.listen({ port: options.port ?? 3000, host: options.host ?? '0.0.0.0' }, function (err, address) {
18
- if (err) {
19
- fastify.log.error(err);
20
- process.exit(1);
21
- }
22
- if (options.startingPeer) {
23
- const startupService = new StartupService(fastify.log, service, options.startingPeer, process.env.BASE_URL, process.env.SIGNING_PUBLIC_KEY, process.env.SIGNING_PRIVATE_KEY);
24
- Promise.resolve(startupService.seedFromPeer()).catch(err => {
25
- fastify.log.error(err);
26
- process.exit(1);
27
- });
45
+ const config = resolveConfig(options, this.resources);
46
+ const db = await openDatabase(options.databasePath ?? DEFAULT_DATABASE_PATH);
47
+ fastify.addHook('onClose', async () => db.close());
48
+ const service = createService(db, this.resources, config, { approveGrant: options.approveGrant });
49
+ // A configured chain may be older than its stored renewal, or long expired; neither should stop the node.
50
+ for (const chain of options.grants ?? []) {
51
+ if (!await service.grantService.seed(chain)) {
52
+ fastify.log.warn(`Skipped expired grant ${chain[chain.length - 1]?.id} from the start options`);
28
53
  }
29
- fastify.log.info(`server listening on ${address}`);
54
+ }
55
+ this.service = service;
56
+ // Each run is scheduled after the previous one finishes, so slow renewals never overlap.
57
+ const refreshIntervalMs = options.grantRefreshIntervalMs ?? DEFAULT_GRANT_REFRESH_INTERVAL_MS;
58
+ let refresher;
59
+ let closed = false;
60
+ const scheduleRefresh = () => {
61
+ refresher = setTimeout(async () => {
62
+ try {
63
+ const { failed, removed } = await service.grantService.refreshDue();
64
+ for (const { id, error } of failed) {
65
+ fastify.log.warn(`Failed to renew grant ${id}: ${error}`);
66
+ }
67
+ for (const id of removed) {
68
+ fastify.log.info(`Removed long-expired grant ${id}: its issuer refused renewal`);
69
+ }
70
+ }
71
+ catch (error) {
72
+ fastify.log.error(error, 'Failed to renew grants');
73
+ }
74
+ if (!closed) {
75
+ scheduleRefresh();
76
+ }
77
+ }, refreshIntervalMs);
78
+ refresher.unref();
79
+ };
80
+ scheduleRefresh();
81
+ fastify.addHook('onClose', async () => {
82
+ closed = true;
83
+ clearTimeout(refresher);
30
84
  });
85
+ await fastify.register(routes, { prefix: '/v1', service, rateLimit: options.rateLimit });
86
+ const address = await fastify.listen({ port: options.port ?? 3000, host: options.host ?? '0.0.0.0' });
87
+ fastify.log.info(`server listening on ${address}`);
88
+ if (options.startingPeer) {
89
+ const startupService = new StartupService(fastify.log, service, options.startingPeer, config.baseUrl, config.publicKey, config.privateKey);
90
+ await startupService.seedFromPeer();
91
+ }
92
+ }
93
+ /** Issues a root grant for this node's resources. Hand the returned chain to the audience. */
94
+ async issueGrant(audience, capabilities, options) {
95
+ return this.started().grantService.issue(audience, capabilities, options);
96
+ }
97
+ /**
98
+ * Narrows a grant this node holds for another audience. Hand the returned chain to the audience.
99
+ * Name the held grant by its id, or by its issuer and id when two issuers used the same id.
100
+ */
101
+ async delegateGrant(heldGrant, audience, capabilities, options) {
102
+ return this.started().grantService.delegate(heldGrant, audience, capabilities, options);
103
+ }
104
+ /** Asks another node for a grant on its resources, and stores it. Throws if refused. */
105
+ async requestGrant(providerBaseUrl, capabilities, details) {
106
+ return this.started().grantService.request(providerBaseUrl, capabilities, details);
107
+ }
108
+ /** Stores a grant chain addressed to this node. */
109
+ async addGrant(chain) {
110
+ return this.started().grantService.add(chain);
111
+ }
112
+ /**
113
+ * Refuses the grant, and every grant delegated from it, in calls to this node, and stops its renewal.
114
+ * Name a grant this node issued by its id, and any other grant by its issuer and id.
115
+ */
116
+ async revokeGrant(grant) {
117
+ return this.started().grantService.revoke(grant);
118
+ }
119
+ /** Refuses this caller key in restricted calls, renewals, grant requests and paid calls. */
120
+ async blockCaller(publicKey, reason) {
121
+ return this.started().grantService.block(publicKey, reason);
122
+ }
123
+ /** False when the key wasn't blocked. */
124
+ async unblockCaller(publicKey) {
125
+ return this.started().grantService.unblock(publicKey);
126
+ }
127
+ started() {
128
+ if (!this.service) {
129
+ throw new Error('StellariumNode.start must be called first');
130
+ }
131
+ return this.service;
31
132
  }
32
133
  }