stellarium-ts 0.12.0 → 0.14.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 +93 -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 +7 -2
  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 +56 -9
  29. package/dist/resource/repository.d.ts.map +1 -1
  30. package/dist/resource/repository.js +140 -68
  31. package/dist/routes.d.ts +8 -0
  32. package/dist/routes.d.ts.map +1 -1
  33. package/dist/routes.js +310 -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 +182 -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,75 +150,85 @@ 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,
41
178
  name: resource.name,
179
+ website: resource.website,
180
+ description: resource.description,
42
181
  functions: resource.functions.map((fn) => ({
43
182
  name: fn.name,
44
183
  returnType: fn.returnType,
45
- parameters: fn.parameters,
184
+ description: fn.description,
185
+ parameters: fn.parameters.map(parameter => ({
186
+ name: parameter.name,
187
+ type: parameter.type,
188
+ description: parameter.description,
189
+ })),
46
190
  paymentManifest: fn.paymentManifest ? ({
47
191
  costLamports: fn.paymentManifest.costLamports,
48
192
  recipientAddress: fn.paymentManifest.recipientAddress,
49
193
  }) : undefined,
194
+ restricted: fn.restricted || undefined,
50
195
  })),
51
196
  baseUrl: resource.baseUrl,
52
197
  })),
198
+ nextOffset,
53
199
  };
54
200
  });
55
- fastify.put('/all-resources', async (request, reply) => {
201
+ fastify.put('/all-resources', { schema: { body: resourceRequestSchema } }, async (request, reply) => {
56
202
  const resourceRequest = request.body;
57
203
  const resources = resourceRequest.resources;
58
204
  const existingPeer = await options.service.peerRepository.getPeerByPublicKey(resourceRequest.publicKey);
59
205
  if (!existingPeer) {
60
206
  return reply.status(400).send({ error: 'Peer not found' });
61
207
  }
62
- const success = await options.service.resourceRepository.saveAll(resources, existingPeer, resourceRequest.signature, resourceRequest.expiry);
63
- if (!success) {
208
+ const result = await options.service.resourceRepository.saveAll(resources, existingPeer, resourceRequest.signature, resourceRequest.expiry);
209
+ if (!result.ok) {
64
210
  return reply.status(400).send({ error: 'Invalid signature' });
65
211
  }
66
- return { success: true };
212
+ // Resources this peer may not publish (not served from its base URL, or owned elsewhere).
213
+ return { success: true, rejected: result.rejected };
67
214
  });
68
215
  // End Meta Routes
69
216
  // Start Run Routes
70
- fastify.post('/run', async (request, reply) => {
217
+ // Authenticate before body validation, so unauthenticated callers learn nothing about the schema.
218
+ fastify.post('/run', { schema: { body: runRequestSchema }, preValidation: requireApiKey }, async (request, reply) => {
71
219
  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' });
220
+ let script;
221
+ try {
222
+ script = parseScript(runRequest.source);
76
223
  }
77
- const apiKey = authorizationHeader.split(' ')[1];
78
- if (apiKey !== process.env.API_KEY) {
79
- return reply.status(401).send({ error: 'Invalid API Key' });
224
+ catch (error) {
225
+ // A script that doesn't parse is the caller's mistake, not a server failure.
226
+ return reply.status(400).send({ error: 'Invalid script', details: error instanceof Error ? error.message : undefined });
227
+ }
228
+ if (script.declaration.kind !== 'request') {
229
+ return reply.status(400).send({ error: 'Invalid script' });
80
230
  }
81
231
  try {
82
- const script = parseScript(runRequest.source);
83
- if (script.declaration.kind !== 'request') {
84
- return reply.status(400).send({ error: 'Invalid script' });
85
- }
86
232
  const result = await interpretParsed(script, {
87
233
  resourceResolver: options.service.resourceResolver,
88
234
  resourceInvoker: options.service.resourceInvoker
@@ -94,34 +240,149 @@ const routes = async (fastify, options) => {
94
240
  reply.status(500).send({ error: 'Internal server error' });
95
241
  }
96
242
  });
97
- fastify.post('/run/resource', async (request, reply) => {
243
+ fastify.post('/run/resource', {
244
+ schema: { body: resourceRunRequestSchema },
245
+ config: { rateLimit: options.rateLimit ?? { max: 100, timeWindow: '1 minute' } },
246
+ }, async (request, reply) => {
98
247
  const resourceRequest = request.body;
99
248
  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
249
  const resource = await options.service.resourceRepository.getInternalResource(resourceRequest.resourcePath, resourceRequest.resourceName);
107
250
  if (!resource) {
108
251
  return reply.status(400).send({ error: 'Resource not found' });
109
252
  }
253
+ const resourceFunction = resource.functions.find(fn => fn.name === resourceRequest.functionName);
254
+ if (!resourceFunction) {
255
+ return reply.status(400).send({ error: 'Function not found' });
256
+ }
257
+ // Grant constraints and the function both read parameters by name, so each name must be
258
+ // declared and appear once. Otherwise a duplicate or extra parameter could pass a
259
+ // constraint check with one value and reach the function with another.
260
+ const declared = new Set(resourceFunction.parameters.map(parameter => parameter.name));
261
+ const names = resourceRequest.parameters.map(parameter => parameter.name);
262
+ if (new Set(names).size !== names.length || !names.every(name => declared.has(name))) {
263
+ return reply.status(400).send({ error: 'Parameters must be declared by the function and appear once each' });
264
+ }
265
+ // A restricted function is never run without a grant from this node. Checked before the
266
+ // voucher, so an unauthorized caller never gets near settlement.
267
+ if (resourceFunction.restricted) {
268
+ // Tells a caller without a usable grant where it can ask for one.
269
+ const grantEndpoint = options.service.grantService.acceptsRequests ? `${options.service.config.baseUrl}/v1/grants/request` : undefined;
270
+ if (!resourceRequest.authorization) {
271
+ return reply.status(403).send({ error: 'Authorization is required', grantEndpoint });
272
+ }
273
+ if (!await options.service.grantService.verifyInvocation(resourceRequest.authorization, resourceRequest)) {
274
+ return reply.status(403).send({ error: 'Invalid authorization', grantEndpoint });
275
+ }
276
+ }
277
+ // Payment manifests are per function. A paid function is never run without a valid voucher.
278
+ const paymentManifest = resourceFunction.paymentManifest;
279
+ if (paymentManifest) {
280
+ if (!paymentVoucher) {
281
+ return reply.status(402).send({ error: 'Payment voucher is required' });
282
+ }
283
+ if (!isValidPaymentVoucher(paymentVoucher, paymentManifest, resourceRequest)) {
284
+ return reply.status(400).send({ error: 'Invalid payment voucher' });
285
+ }
286
+ if (await options.service.grantService.isBlocked(paymentVoucher.publicKey)) {
287
+ return reply.status(403).send({ error: 'Caller is blocked' });
288
+ }
289
+ }
110
290
  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) {
291
+ // Settle only verified vouchers, and only after a successful invoke. A voucher sent to a free function is ignored.
292
+ // Run this on a separate thread to avoid blocking the main thread.
293
+ if (paymentManifest && paymentVoucher && !result.error) {
113
294
  setTimeout(async () => {
114
- await options.service.operatorClient.settlePayment({
115
- voucher: paymentVoucher,
116
- resourcePath: resourceRequest.resourcePath,
117
- resourceName: resourceRequest.resourceName,
118
- parameters: resourceRequest.parameters,
119
- });
295
+ // Nothing awaits this callback, so an uncaught error here would be an unhandled rejection.
296
+ try {
297
+ await options.service.operatorClient.settlePayment({ voucher: paymentVoucher });
298
+ }
299
+ catch (error) {
300
+ fastify.log.error(error, 'Failed to settle payment');
301
+ }
120
302
  }, 0);
121
303
  }
122
304
  return result;
123
305
  });
124
306
  // End Run Routes
307
+ // Start Grant Routes
308
+ /** Issues a root grant to the request's signer, if the node's approval hook allows it. */
309
+ fastify.post('/grants/request', {
310
+ schema: { body: grantRequestSchema },
311
+ config: { rateLimit: options.rateLimit ?? { max: 100, timeWindow: '1 minute' } },
312
+ }, async (request, reply) => {
313
+ if (!options.service.grantService.acceptsRequests) {
314
+ return reply.status(403).send({ error: 'This node does not issue grants on request' });
315
+ }
316
+ let grants;
317
+ try {
318
+ grants = await options.service.grantService.handleRequest(request.body);
319
+ }
320
+ catch (error) {
321
+ // An approval hook that throws or returns an invalid approval is this node's fault, not the requester's.
322
+ fastify.log.error(error, 'Failed to decide grant request');
323
+ return reply.status(500).send({ error: 'Internal server error' });
324
+ }
325
+ if (!grants) {
326
+ return reply.status(403).send({ error: 'Grant request refused' });
327
+ }
328
+ return { grants };
329
+ });
330
+ /** Re-issues a grant this node issued to the grant's audience, with a new expiry. */
331
+ fastify.post('/grants/renew', {
332
+ schema: { body: grantRenewalRequestSchema },
333
+ config: { rateLimit: options.rateLimit ?? { max: 100, timeWindow: '1 minute' } },
334
+ }, async (request, reply) => {
335
+ const grants = await options.service.grantService.renew(request.body);
336
+ if (!grants) {
337
+ return reply.status(403).send({ error: 'Grant renewal refused' });
338
+ }
339
+ return { grants };
340
+ });
341
+ // End Grant Routes
342
+ // Start Admin Routes
343
+ // Authenticated before validation, like /run, so unauthenticated callers learn nothing about the schema.
344
+ /**
345
+ * Revokes a grant: this node refuses it in any chain, with everything delegated from it, and
346
+ * stops renewing it. Without `?issuer=`, the grant is one this node issued. With it, the grant
347
+ * may be one delegated further down a chain, so it can be cut off too.
348
+ */
349
+ fastify.put('/admin/revoked-grants/:grantId', { preValidation: requireApiKey, schema: { querystring: revokeQuerySchema } }, async (request) => {
350
+ const { grantId } = request.params;
351
+ const { issuer } = request.query;
352
+ await options.service.grantService.revoke(issuer === undefined ? grantId : { issuer, id: grantId });
353
+ return { success: true };
354
+ });
355
+ fastify.get('/admin/blocked-callers', { preValidation: requireApiKey, schema: { querystring: pageQuerySchema } }, async (request) => {
356
+ const { items: callers, nextOffset } = await getPage(request.query, (offset, limit) => options.service.grantService.getBlocked(offset, limit));
357
+ return { callers, nextOffset };
358
+ });
359
+ /** Blocks a caller key, with an optional `{ "reason" }` body. Unrestricted free functions stay open to it, since their callers are anonymous. */
360
+ fastify.put('/admin/blocked-callers/:publicKey', { preValidation: requireApiKey }, async (request, reply) => {
361
+ const { publicKey } = request.params;
362
+ // The body is optional, so it is checked here rather than by a schema that would require one.
363
+ const reason = request.body?.reason;
364
+ if (reason !== undefined && typeof reason !== 'string') {
365
+ return reply.status(400).send({ error: 'reason must be a string' });
366
+ }
367
+ try {
368
+ await options.service.grantService.block(publicKey, reason);
369
+ }
370
+ catch (error) {
371
+ if (error instanceof InvalidPublicKeyError) {
372
+ return reply.status(400).send({ error: error.message });
373
+ }
374
+ throw error;
375
+ }
376
+ return { success: true };
377
+ });
378
+ fastify.delete('/admin/blocked-callers/:publicKey', { preValidation: requireApiKey }, async (request, reply) => {
379
+ const { publicKey } = request.params;
380
+ if (!await options.service.grantService.unblock(publicKey)) {
381
+ return reply.status(404).send({ error: 'Caller is not blocked' });
382
+ }
383
+ return { success: true };
384
+ });
385
+ // End Admin Routes
125
386
  };
126
387
  // ESM
127
388
  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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"}