stellarium-ts 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/client/operatorClient.d.ts +34 -3
  2. package/dist/client/operatorClient.d.ts.map +1 -1
  3. package/dist/client/operatorClient.js +15 -0
  4. package/dist/contract/repository.d.ts +49 -0
  5. package/dist/contract/repository.d.ts.map +1 -0
  6. package/dist/contract/repository.js +80 -0
  7. package/dist/contract/service.d.ts +103 -0
  8. package/dist/contract/service.d.ts.map +1 -0
  9. package/dist/contract/service.js +297 -0
  10. package/dist/contract/state.d.ts +43 -0
  11. package/dist/contract/state.d.ts.map +1 -0
  12. package/dist/contract/state.js +79 -0
  13. package/dist/db/database.d.ts +25 -0
  14. package/dist/db/database.d.ts.map +1 -0
  15. package/dist/db/database.js +1 -0
  16. package/dist/db/db.d.ts +12 -4
  17. package/dist/db/db.d.ts.map +1 -1
  18. package/dist/db/db.js +97 -24
  19. package/dist/db/postgres.d.ts +39 -0
  20. package/dist/db/postgres.d.ts.map +1 -0
  21. package/dist/db/postgres.js +132 -0
  22. package/dist/db/sqlite.d.ts +16 -0
  23. package/dist/db/sqlite.d.ts.map +1 -0
  24. package/dist/db/sqlite.js +53 -0
  25. package/dist/grant/repository.d.ts +2 -2
  26. package/dist/grant/repository.d.ts.map +1 -1
  27. package/dist/grant/repository.js +2 -2
  28. package/dist/index.d.ts +3 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -1
  31. package/dist/peer/repository.d.ts +2 -2
  32. package/dist/peer/repository.d.ts.map +1 -1
  33. package/dist/peer/repository.js +1 -1
  34. package/dist/resource/invoker.d.ts +85 -0
  35. package/dist/resource/invoker.d.ts.map +1 -1
  36. package/dist/resource/invoker.js +69 -6
  37. package/dist/resource/repository.d.ts +51 -10
  38. package/dist/resource/repository.d.ts.map +1 -1
  39. package/dist/resource/repository.js +116 -61
  40. package/dist/resource/settlement.d.ts +5 -0
  41. package/dist/resource/settlement.d.ts.map +1 -1
  42. package/dist/resource/settlement.js +19 -0
  43. package/dist/routes.d.ts.map +1 -1
  44. package/dist/routes.js +161 -25
  45. package/dist/server.d.ts +30 -1
  46. package/dist/server.d.ts.map +1 -1
  47. package/dist/server.js +80 -5
  48. package/dist/service.d.ts +27 -2
  49. package/dist/service.d.ts.map +1 -1
  50. package/dist/service.js +16 -1
  51. package/dist/startup.d.ts +2 -2
  52. package/dist/startup.d.ts.map +1 -1
  53. package/dist/startup.js +43 -34
  54. package/dist/tests/contract/repository.test.d.ts +2 -0
  55. package/dist/tests/contract/repository.test.d.ts.map +1 -0
  56. package/dist/tests/contract/repository.test.js +68 -0
  57. package/dist/tests/contract/routes.test.d.ts +2 -0
  58. package/dist/tests/contract/routes.test.d.ts.map +1 -0
  59. package/dist/tests/contract/routes.test.js +252 -0
  60. package/dist/tests/contract/state-routes.test.d.ts +2 -0
  61. package/dist/tests/contract/state-routes.test.d.ts.map +1 -0
  62. package/dist/tests/contract/state-routes.test.js +158 -0
  63. package/dist/tests/contract/state.test.d.ts +2 -0
  64. package/dist/tests/contract/state.test.d.ts.map +1 -0
  65. package/dist/tests/contract/state.test.js +57 -0
  66. package/dist/tests/db/db.test.js +1 -1
  67. package/dist/tests/db/postgres.test.d.ts +2 -0
  68. package/dist/tests/db/postgres.test.d.ts.map +1 -0
  69. package/dist/tests/db/postgres.test.js +43 -0
  70. package/dist/tests/grant/service.test.js +3 -4
  71. package/dist/tests/helpers.d.ts +8 -0
  72. package/dist/tests/helpers.d.ts.map +1 -1
  73. package/dist/tests/helpers.js +37 -0
  74. package/dist/tests/integration/network.test.js +74 -4
  75. package/dist/tests/peer/repository.test.js +3 -4
  76. package/dist/tests/resource/repository.test.js +3 -4
  77. package/dist/tests/routes.test.js +10 -11
  78. package/dist/tests/server.test.js +30 -0
  79. package/dist/tests/startup.test.js +4 -5
  80. package/package.json +5 -2
@@ -1,5 +1,34 @@
1
1
  import { isFreshExpiry, safeVerifyData } from "../crypto/utils.js";
2
2
  import { isInferenceManifest } from "./inference.js";
3
+ /** The one function a contract's resource has. It runs the contract's latest version. */
4
+ export const CONTRACT_FUNCTION = 'run';
5
+ export function isContractManifest(manifest) {
6
+ return typeof manifest === 'object' && manifest !== null && 'priceMicroUsdPerMillionTokens' in manifest;
7
+ }
8
+ export function isContractFunction(fn) {
9
+ return fn.kind === 'contract';
10
+ }
11
+ /** The resource a contract is served as. Its function always has a manifest, so every call needs a voucher. */
12
+ export function contractResource(contract, baseUrl, priceMicroUsdPerMillionTokens, recipientOrganizationId) {
13
+ return {
14
+ id: 0,
15
+ baseUrl,
16
+ metadata: {},
17
+ path: contract.path,
18
+ name: contract.name,
19
+ description: contract.description ?? undefined,
20
+ functions: [{
21
+ name: CONTRACT_FUNCTION,
22
+ kind: 'contract',
23
+ parameters: contract.parameters,
24
+ returnType: contract.returnType,
25
+ exec: async () => {
26
+ throw new Error('Contracts are run by the resource invoker');
27
+ },
28
+ paymentManifest: { priceMicroUsdPerMillionTokens, recipientOrganizationId },
29
+ }],
30
+ };
31
+ }
3
32
  /**
4
33
  * The object a node signs to broadcast its resources. Key order is part of the signed bytes.
5
34
  * Functions' `exec` implementations are dropped by JSON serialization on both ends.
@@ -25,49 +54,53 @@ export class ResourceRepository {
25
54
  resources;
26
55
  baseUrl;
27
56
  organizationId;
28
- // The connection is shared, so transactions are serialized to keep them from nesting.
29
- writes = Promise.resolve();
30
- /** `organizationId` is the operator organization this node is enrolled in, which is paid for its functions. */
31
- constructor(db, resources = [], baseUrl = '', organizationId) {
57
+ contracts;
58
+ /**
59
+ * `organizationId` is the operator organization this node is enrolled in, which is paid for its functions.
60
+ * The contracts deployed to the node are its resources too, after the registered ones.
61
+ */
62
+ constructor(db, resources = [], baseUrl = '', organizationId, contracts) {
32
63
  this.db = db;
33
64
  this.resources = resources;
34
65
  this.baseUrl = baseUrl;
35
66
  this.organizationId = organizationId;
67
+ this.contracts = contracts;
36
68
  }
37
- serialize(work) {
38
- const run = this.writes.then(work, work);
39
- this.writes = run.catch(() => undefined);
40
- return run;
69
+ /** A resource registered on this node. */
70
+ isRegistered(resource) {
71
+ return this.resources.some(r => r.path === resource.path && r.name === resource.name);
41
72
  }
42
- transaction(work) {
43
- return this.serialize(async () => {
44
- await this.db.exec('BEGIN IMMEDIATE');
45
- try {
46
- const result = await work();
47
- await this.db.exec('COMMIT');
48
- return result;
49
- }
50
- catch (error) {
51
- await this.db.exec('ROLLBACK');
52
- throw error;
53
- }
54
- });
73
+ /** A resource this node hosts: registered, or a contract deployed to it. */
74
+ async isInternal(resource) {
75
+ return this.isRegistered(resource) || await this.contracts?.repository.owner(resource.path, resource.name) !== undefined;
55
76
  }
56
- isInternal(resource) {
57
- return this.resources.some(r => r.path === resource.path && r.name === resource.name);
77
+ /** A resource learned from a peer. */
78
+ async isLearned(path, name) {
79
+ return await this.db.get('SELECT id FROM resource WHERE path = ? AND name = ?', [path, name]) !== undefined;
80
+ }
81
+ async contractResources() {
82
+ if (!this.contracts) {
83
+ return [];
84
+ }
85
+ const { repository, price, organizationId } = this.contracts;
86
+ return (await repository.allLatest()).map(contract => contractResource(contract, this.baseUrl, price(), organizationId()));
87
+ }
88
+ async contractResource(path, name) {
89
+ const contract = await this.contracts?.repository.latest(path, name);
90
+ return contract && contractResource(contract, this.baseUrl, this.contracts.price(), this.contracts.organizationId());
58
91
  }
59
92
  /**
60
93
  * Saves a resource learned from the starting peer, if it isn't known yet. Resources hosted
61
94
  * by this node and resources already known are left alone, so startup can run on every boot.
62
95
  */
63
96
  async learn(resource) {
64
- if (this.isInternal(resource) || resource.baseUrl === this.baseUrl) {
97
+ if (await this.isInternal(resource) || resource.baseUrl === this.baseUrl) {
65
98
  return;
66
99
  }
67
- await this.transaction(async () => {
68
- const existing = await this.db.get('SELECT id FROM resource WHERE path = ? AND name = ?', [resource.path, resource.name]);
100
+ await this.db.transaction(async (tx) => {
101
+ const existing = await tx.get('SELECT id FROM resource WHERE path = ? AND name = ?', [resource.path, resource.name]);
69
102
  if (!existing) {
70
- await this.insert(resource, null);
103
+ await this.insert(tx, resource, null);
71
104
  }
72
105
  });
73
106
  }
@@ -83,15 +116,17 @@ export class ResourceRepository {
83
116
  return { ok: false };
84
117
  }
85
118
  const rejected = [];
86
- await this.transaction(async () => {
87
- for (const resource of resources) {
88
- if (resource.baseUrl !== peer.baseUrl || this.isInternal(resource)) {
119
+ // Checked before the transaction, whose work may use only its own connection.
120
+ const internal = await Promise.all(resources.map(resource => this.isInternal(resource)));
121
+ await this.db.transaction(async (tx) => {
122
+ for (const [index, resource] of resources.entries()) {
123
+ if (resource.baseUrl !== peer.baseUrl || internal[index]) {
89
124
  rejected.push(resourceKey(resource));
90
125
  continue;
91
126
  }
92
- const existing = await this.db.get('SELECT * FROM resource WHERE path = ? AND name = ?', [resource.path, resource.name]);
127
+ const existing = await tx.get('SELECT * FROM resource WHERE path = ? AND name = ?', [resource.path, resource.name]);
93
128
  if (!existing) {
94
- await this.insert(resource, peer.id);
129
+ await this.insert(tx, resource, peer.id);
95
130
  continue;
96
131
  }
97
132
  // Unowned rows came from the starting peer; the peer they point at may claim them.
@@ -100,37 +135,39 @@ export class ResourceRepository {
100
135
  rejected.push(resourceKey(resource));
101
136
  continue;
102
137
  }
103
- await this.deleteFunctions(existing.id);
104
- await this.db.run('UPDATE resource SET base_url = ?, website = ?, description = ?, peer_id = ?, updated_at = ? WHERE id = ?', [resource.baseUrl, optionalWebsite(resource.website), optionalText(resource.description), peer.id, new Date().toISOString(), existing.id]);
105
- await this.insertFunctions(existing.id, resource.functions);
138
+ await this.deleteFunctions(tx, existing.id);
139
+ await tx.run('UPDATE resource SET base_url = ?, website = ?, description = ?, peer_id = ?, updated_at = ? WHERE id = ?', [resource.baseUrl, optionalWebsite(resource.website), optionalText(resource.description), peer.id, new Date().toISOString(), existing.id]);
140
+ await this.insertFunctions(tx, existing.id, resource.functions);
106
141
  }
107
142
  });
108
143
  return { ok: true, rejected };
109
144
  }
110
- async insert(resource, peerId) {
145
+ async insert(tx, resource, peerId) {
111
146
  const createdAt = new Date().toISOString();
112
- const result = await this.db.run('INSERT INTO resource (path, name, base_url, website, description, peer_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [resource.path, resource.name, resource.baseUrl, optionalWebsite(resource.website), optionalText(resource.description), peerId, createdAt, createdAt]);
113
- if (!result.changes || result.lastID === undefined) {
147
+ const row = await tx.get('INSERT INTO resource (path, name, base_url, website, description, peer_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id', [resource.path, resource.name, resource.baseUrl, optionalWebsite(resource.website), optionalText(resource.description), peerId, createdAt, createdAt]);
148
+ if (!row) {
114
149
  throw new Error('Failed to create resource');
115
150
  }
116
- await this.insertFunctions(result.lastID, resource.functions);
151
+ await this.insertFunctions(tx, row.id, resource.functions);
117
152
  }
118
- async insertFunctions(resourceId, functions) {
153
+ async insertFunctions(tx, resourceId, functions) {
119
154
  const createdAt = new Date().toISOString();
120
155
  for (const fn of functions) {
121
- const functionResult = await this.db.run('INSERT INTO resource_function (resource_id, name, return_type, description, restricted, kind, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [resourceId, fn.name, fn.returnType, optionalText(fn.description), fn.restricted === true ? 1 : 0, fn.kind === 'inference' ? 'inference' : 'fixed', createdAt, createdAt]);
122
- const functionId = functionResult.lastID;
156
+ const functionRow = await tx.get('INSERT INTO resource_function (resource_id, name, return_type, description, restricted, kind, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id', [resourceId, fn.name, fn.returnType, optionalText(fn.description), fn.restricted === true ? 1 : 0, fn.kind === 'inference' || fn.kind === 'contract' ? fn.kind : 'fixed', createdAt, createdAt]);
157
+ const functionId = functionRow.id;
123
158
  for (const parameter of fn.parameters) {
124
- await this.db.run('INSERT INTO resource_function_parameter (resource_function_id, name, parameter_type, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', [functionId, parameter.name, parameter.type, optionalText(parameter.description), createdAt, createdAt]);
159
+ await tx.run('INSERT INTO resource_function_parameter (resource_function_id, name, parameter_type, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', [functionId, parameter.name, parameter.type, optionalText(parameter.description), createdAt, createdAt]);
125
160
  }
126
161
  const manifest = fn.paymentManifest;
127
162
  if (manifest) {
128
- const inference = isInferenceManifest(manifest);
129
- await this.db.run('INSERT INTO resource_function_payment_manifest (resource_function_id, cost_micro_usd, input_cost_micro_usd_per_million_tokens, output_cost_micro_usd_per_million_tokens, recipient_organization_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [
163
+ const inference = isInferenceManifest(manifest) ? manifest : undefined;
164
+ const contract = isContractManifest(manifest) ? manifest : undefined;
165
+ await tx.run('INSERT INTO resource_function_payment_manifest (resource_function_id, cost_micro_usd, input_cost_micro_usd_per_million_tokens, output_cost_micro_usd_per_million_tokens, price_micro_usd_per_million_tokens, recipient_organization_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [
130
166
  functionId,
131
- inference ? null : manifest.costMicroUsd,
132
- inference ? manifest.inputCostMicroUsdPerMillionTokens : null,
133
- inference ? manifest.outputCostMicroUsdPerMillionTokens : null,
167
+ inference || contract ? null : manifest.costMicroUsd,
168
+ inference ? inference.inputCostMicroUsdPerMillionTokens : null,
169
+ inference ? inference.outputCostMicroUsdPerMillionTokens : null,
170
+ contract ? contract.priceMicroUsdPerMillionTokens : null,
134
171
  manifest.recipientOrganizationId ?? null,
135
172
  createdAt,
136
173
  createdAt,
@@ -138,14 +175,14 @@ export class ResourceRepository {
138
175
  }
139
176
  }
140
177
  }
141
- async deleteFunctions(resourceId) {
178
+ async deleteFunctions(tx, resourceId) {
142
179
  const functionIds = 'SELECT id FROM resource_function WHERE resource_id = ?';
143
- await this.db.run(`DELETE FROM resource_function_payment_manifest WHERE resource_function_id IN (${functionIds})`, [resourceId]);
144
- await this.db.run(`DELETE FROM resource_function_parameter WHERE resource_function_id IN (${functionIds})`, [resourceId]);
145
- await this.db.run('DELETE FROM resource_function WHERE resource_id = ?', [resourceId]);
180
+ await tx.run(`DELETE FROM resource_function_payment_manifest WHERE resource_function_id IN (${functionIds})`, [resourceId]);
181
+ await tx.run(`DELETE FROM resource_function_parameter WHERE resource_function_id IN (${functionIds})`, [resourceId]);
182
+ await tx.run('DELETE FROM resource_function WHERE resource_id = ?', [resourceId]);
146
183
  }
147
184
  async getInternalResource(path, name) {
148
- return this.resources.find(r => r.path === path && r.name === name);
185
+ return this.resources.find(r => r.path === path && r.name === name) ?? await this.contractResource(path, name);
149
186
  }
150
187
  async getResource(key) {
151
188
  const resource = this.resources.find(r => resourceKey(r) === key);
@@ -156,6 +193,10 @@ export class ResourceRepository {
156
193
  const pathName = key.split('.');
157
194
  const name = pathName.pop();
158
195
  const path = pathName.join('.');
196
+ const contract = await this.contractResource(path, name);
197
+ if (contract) {
198
+ return contract;
199
+ }
159
200
  const result = await this.db.get('SELECT * FROM resource WHERE path = ? AND name = ?', [path, name]);
160
201
  if (!result) {
161
202
  return undefined;
@@ -170,19 +211,25 @@ export class ResourceRepository {
170
211
  }
171
212
  };
172
213
  }
173
- /** This node's resources, with its base URL and its organization as the recipient of every paid function. */
214
+ /**
215
+ * This node's resources, with its base URL and its organization as the recipient of every paid function:
216
+ * the registered ones, then its contracts by path and name.
217
+ */
174
218
  async getInternalResources() {
175
- return this.resources.map(r => ({
219
+ const registered = this.resources.map(r => ({
176
220
  ...r,
177
221
  baseUrl: this.baseUrl,
178
222
  functions: r.functions.map((fn) => fn.paymentManifest
179
223
  ? { ...fn, paymentManifest: { ...fn.paymentManifest, recipientOrganizationId: this.organizationId } }
180
224
  : fn),
181
225
  }));
226
+ return [...registered, ...await this.contractResources()];
182
227
  }
183
228
  /** Learned resources in insertion order. A negative `limit` means no limit. */
184
229
  async getExternalResources(offset = 0, limit = -1) {
185
- const results = await this.db.all('SELECT * FROM resource ORDER BY id LIMIT ? OFFSET ?', [limit, offset]);
230
+ const results = await this.db.all('SELECT * FROM resource ORDER BY id LIMIT ? OFFSET ?',
231
+ // Postgres refuses a negative limit, and SQLite needs one before an offset.
232
+ [limit < 0 ? Number.MAX_SAFE_INTEGER : limit, offset]);
186
233
  return Promise.all(results.map(async (r) => ({
187
234
  id: r.id,
188
235
  path: r.path,
@@ -212,11 +259,11 @@ export class ResourceRepository {
212
259
  exec: async () => undefined,
213
260
  paymentManifest: await this.getResourceFunctionPaymentManifest(fn.id),
214
261
  restricted: fn.restricted === 1 || undefined,
215
- kind: fn.kind === 'inference' ? 'inference' : undefined,
262
+ kind: fn.kind === 'inference' || fn.kind === 'contract' ? fn.kind : undefined,
216
263
  };
217
264
  }
218
265
  async getResourceFunctions(resourceId) {
219
- const rows = await this.db.all('SELECT * FROM resource_function WHERE resource_id = ?', [resourceId]);
266
+ const rows = await this.db.all('SELECT * FROM resource_function WHERE resource_id = ? ORDER BY name', [resourceId]);
220
267
  return Promise.all(rows.map(async (f) => ({
221
268
  id: f.id,
222
269
  name: f.name,
@@ -226,7 +273,7 @@ export class ResourceRepository {
226
273
  exec: async () => undefined,
227
274
  paymentManifest: await this.getResourceFunctionPaymentManifest(f.id),
228
275
  restricted: f.restricted === 1 || undefined,
229
- kind: f.kind === 'inference' ? 'inference' : undefined,
276
+ kind: f.kind === 'inference' || f.kind === 'contract' ? f.kind : undefined,
230
277
  })));
231
278
  }
232
279
  async getResourceParameters(resourceFunctionId) {
@@ -241,6 +288,13 @@ export class ResourceRepository {
241
288
  if (!result) {
242
289
  return undefined;
243
290
  }
291
+ if (result.price_micro_usd_per_million_tokens !== null) {
292
+ return {
293
+ id: result.id,
294
+ priceMicroUsdPerMillionTokens: result.price_micro_usd_per_million_tokens,
295
+ recipientOrganizationId: result.recipient_organization_id ?? undefined,
296
+ };
297
+ }
244
298
  if (result.cost_micro_usd === null) {
245
299
  return {
246
300
  id: result.id,
@@ -260,12 +314,13 @@ export class ResourceRepository {
260
314
  * Both are in a fixed order, so offsets stay stable as resources are learned.
261
315
  */
262
316
  async getResources(offset, limit) {
263
- const internal = (await this.getInternalResources()).slice(offset, offset + limit);
317
+ const all = await this.getInternalResources();
318
+ const internal = all.slice(offset, offset + limit);
264
319
  const remaining = limit - internal.length;
265
320
  if (remaining <= 0) {
266
321
  return internal;
267
322
  }
268
- const externalOffset = Math.max(0, offset - this.resources.length);
323
+ const externalOffset = Math.max(0, offset - all.length);
269
324
  return [...internal, ...await this.getExternalResources(externalOffset, remaining)];
270
325
  }
271
326
  }
@@ -22,4 +22,9 @@ export interface PaymentRefusal {
22
22
  * scheduled after returning, so it doesn't delay the caller, and failures go to `onSettleError`.
23
23
  */
24
24
  export declare function settlePaidCall(operatorClient: OperatorClient, voucher: PaymentVoucher | InferencePaymentVoucher, call: () => Promise<InvokeResult>, onSettleError: (error: unknown, message: string) => void): Promise<InvokeResult | PaymentRefusal>;
25
+ /**
26
+ * Captures a billed run with the tokens it executed, or releases it, after the response: nothing waits on
27
+ * settlement.
28
+ */
29
+ export declare function settleRunLater(operatorClient: OperatorClient, runId: string, outcome: 'capture' | 'release', tokens: number, onSettleError: (error: unknown, message: string) => void): void;
25
30
  //# sourceMappingURL=settlement.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"settlement.d.ts","sourceRoot":"","sources":["../../src/resource/settlement.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAqB,MAAM,6BAA6B,CAAC;AACrF,OAAO,EAAmB,KAAK,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACtE,OAAO,EAAE,KAAK,uBAAuB,EAAsB,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAErG,sFAAsF;AACtF,MAAM,WAAW,YAAY;IACzB,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAClC,KAAK,CAAC,EAAE,cAAc,CAAC;CAC1B;AAED,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC3B,OAAO,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC9C;AAYD;;;;GAIG;AACH,wBAAsB,cAAc,CAChC,cAAc,EAAE,cAAc,EAC9B,OAAO,EAAE,cAAc,GAAG,uBAAuB,EACjD,IAAI,EAAE,MAAM,OAAO,CAAC,YAAY,CAAC,EACjC,aAAa,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,GACzD,OAAO,CAAC,YAAY,GAAG,cAAc,CAAC,CAuCxC"}
1
+ {"version":3,"file":"settlement.d.ts","sourceRoot":"","sources":["../../src/resource/settlement.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAqB,MAAM,6BAA6B,CAAC;AACrF,OAAO,EAAmB,KAAK,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACtE,OAAO,EAAE,KAAK,uBAAuB,EAAsB,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAErG,sFAAsF;AACtF,MAAM,WAAW,YAAY;IACzB,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAClC,KAAK,CAAC,EAAE,cAAc,CAAC;CAC1B;AAED,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC3B,OAAO,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC9C;AAYD;;;;GAIG;AACH,wBAAsB,cAAc,CAChC,cAAc,EAAE,cAAc,EAC9B,OAAO,EAAE,cAAc,GAAG,uBAAuB,EACjD,IAAI,EAAE,MAAM,OAAO,CAAC,YAAY,CAAC,EACjC,aAAa,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,GACzD,OAAO,CAAC,YAAY,GAAG,cAAc,CAAC,CAuCxC;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAC1B,cAAc,EAAE,cAAc,EAC9B,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,SAAS,GAAG,SAAS,EAC9B,MAAM,EAAE,MAAM,EACd,aAAa,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,GACzD,IAAI,CAYN"}
@@ -57,3 +57,22 @@ export async function settlePaidCall(operatorClient, voucher, call, onSettleErro
57
57
  settle();
58
58
  return result;
59
59
  }
60
+ /**
61
+ * Captures a billed run with the tokens it executed, or releases it, after the response: nothing waits on
62
+ * settlement.
63
+ */
64
+ export function settleRunLater(operatorClient, runId, outcome, tokens, onSettleError) {
65
+ setTimeout(async () => {
66
+ try {
67
+ if (outcome === 'capture') {
68
+ await operatorClient.captureRun({ runId, tokens });
69
+ }
70
+ else {
71
+ await operatorClient.releaseRun({ runId });
72
+ }
73
+ }
74
+ catch (error) {
75
+ onSettleError(error, `Failed to ${outcome} run ${runId}`);
76
+ }
77
+ }, 0);
78
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAgC,MAAM,SAAS,CAAC;AAEhF,OAAO,EAAgB,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAM1E,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAK5C,MAAM,WAAW,eAAe;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,oDAAoD;IACpD,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC;CAC/B;AAED,UAAU,YAAY;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,eAAe,CAAC;CAC/B;AAoCD;;;GAGG;AACH,KAAK,SAAS,GAAG;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,GAAG,CAAC;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,cAAc,CAAC,CAAC;AAE3F,OAAO,QAAQ,SAAS,CAAC;IACrB,UAAU,cAAc;QACpB,SAAS,CAAC,EAAE,SAAS,CAAC;KACzB;CACJ;AAkBD,eAAO,MAAM,iBAAiB,MAAM,CAAC;AACrC,eAAO,MAAM,aAAa,MAAM,CAAC;AAwKjC;;;;GAIG;AACH,QAAA,MAAM,MAAM,EAAE,kBAAkB,CAAC,YAAY,CAqa5C,CAAC;eAGa,MAAM"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAgC,MAAM,SAAS,CAAC;AAEhF,OAAO,EAAgB,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAQ1E,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAK5C,MAAM,WAAW,eAAe;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,oDAAoD;IACpD,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC;CAC/B;AAED,UAAU,YAAY;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,eAAe,CAAC;CAC/B;AAsCD;;;GAGG;AACH,KAAK,SAAS,GAAG;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,GAAG,CAAC;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,cAAc,CAAC,CAAC;AAE3F,OAAO,QAAQ,SAAS,CAAC;IACrB,UAAU,cAAc;QACpB,SAAS,CAAC,EAAE,SAAS,CAAC;KACzB;CACJ;AAkBD,eAAO,MAAM,iBAAiB,MAAM,CAAC;AACrC,eAAO,MAAM,aAAa,MAAM,CAAC;AAkOjC;;;;GAIG;AACH,QAAA,MAAM,MAAM,EAAE,kBAAkB,CAAC,YAAY,CAigB5C,CAAC;eAGa,MAAM"}
package/dist/routes.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
2
2
  import rateLimit from '@fastify/rate-limit';
3
- import { interpretParsed, parseScript, RequestScriptError, TokenMeter } from 'requestscript';
3
+ import { decodeValue, interpretParsed, parseScript, RequestScriptError, TokenMeter, valueToJson } from 'requestscript';
4
4
  import { looksLikeJwt } from './client/runTokens.js';
5
- import { inferenceVoucherPayload, invocationHash, isInferenceVoucher, paymentVoucherPayload } from './resource/invoker.js';
5
+ import { contractVoucherPayload, inferenceVoucherPayload, invocationHash, isContractVoucher, isInferenceVoucher, MAX_CONTRACT_DEPTH, paymentVoucherPayload } from './resource/invoker.js';
6
6
  import { estimateInputTokens, inferenceCharge, isInferenceFunction, isInferenceManifest, parseMaxTokens } from './resource/inference.js';
7
- import { settlePaidCall } from './resource/settlement.js';
7
+ import { settlePaidCall, settleRunLater } from './resource/settlement.js';
8
+ import { isContractFunction, isContractManifest } from './resource/repository.js';
8
9
  import { safeVerifyData } from './crypto/utils.js';
9
10
  import { InvalidPublicKeyError } from './grant/service.js';
10
11
  // Body schemas check only the shape the handlers rely on. Signed payloads (resources) are
@@ -94,15 +95,37 @@ const inferenceVoucherSchema = {
94
95
  publicKey: { type: 'string' },
95
96
  },
96
97
  };
98
+ const contractVoucherSchema = {
99
+ type: 'object',
100
+ required: ['kind', 'requestId', 'invocationId', 'maxAmount', 'priceMicroUsdPerMillionTokens', 'recipientOrganizationId', 'signature', 'expiresAt', 'invocationHash', 'publicKey'],
101
+ properties: {
102
+ kind: { const: 'contract' },
103
+ requestId: { type: 'string' },
104
+ invocationId: { type: 'string' },
105
+ maxAmount: { type: 'integer', minimum: 0 },
106
+ priceMicroUsdPerMillionTokens: { type: 'integer', minimum: 0 },
107
+ recipientOrganizationId: { type: 'string' },
108
+ signature: { type: 'string' },
109
+ expiresAt: { type: 'string' },
110
+ invocationHash: { type: 'string' },
111
+ publicKey: { type: 'string' },
112
+ },
113
+ };
97
114
  const resourceRunRequestSchema = {
98
115
  type: 'object',
99
116
  required: ['resourcePath', 'resourceName', 'functionName', 'parameters'],
100
117
  properties: {
101
118
  ...resourceCallProperties,
102
- // An inference voucher has `kind` and no `amount`, so at most one shape matches.
103
- paymentVoucher: { oneOf: [fixedVoucherSchema, inferenceVoucherSchema] },
119
+ // Inference and contract vouchers have their own `kind`, and no `amount`, so at most one shape matches.
120
+ paymentVoucher: { oneOf: [fixedVoucherSchema, inferenceVoucherSchema, contractVoucherSchema] },
121
+ contractDepth: { type: 'integer', minimum: 1 },
104
122
  },
105
123
  };
124
+ const contractRequestSchema = {
125
+ type: 'object',
126
+ required: ['source'],
127
+ properties: { source: { type: 'string' }, description: { type: 'string', maxLength: 2048 } },
128
+ };
106
129
  const resourceEstimateRequestSchema = {
107
130
  type: 'object',
108
131
  required: ['resourcePath', 'resourceName', 'functionName', 'parameters'],
@@ -162,7 +185,7 @@ function isValidPaymentVoucher(voucher, resourceFunction, manifest, request, org
162
185
  }
163
186
  return safeVerifyData(inferenceVoucherPayload(voucher), voucher.publicKey, voucher.signature);
164
187
  }
165
- if (isInferenceFunction(resourceFunction) || isInferenceManifest(manifest)) {
188
+ if (isInferenceFunction(resourceFunction) || isInferenceManifest(manifest) || isContractFunction(resourceFunction) || isContractManifest(manifest)) {
166
189
  return false;
167
190
  }
168
191
  if (voucher.amount < manifest.costMicroUsd) {
@@ -170,6 +193,37 @@ function isValidPaymentVoucher(voucher, resourceFunction, manifest, request, org
170
193
  }
171
194
  return safeVerifyData(paymentVoucherPayload(voucher), voucher.publicKey, voucher.signature);
172
195
  }
196
+ /**
197
+ * A contract voucher is valid when it is bound to this exact call, signed by its public key, unexpired, pays
198
+ * this node's organization, and agrees to at least this node's current price.
199
+ */
200
+ function isValidContractVoucher(voucher, request, organizationId, price) {
201
+ const expectedHash = invocationHash(request.resourcePath, request.resourceName, request.functionName, request.parameters);
202
+ if (voucher.invocationHash !== expectedHash || !organizationId || voucher.recipientOrganizationId !== organizationId) {
203
+ return false;
204
+ }
205
+ const expiresAt = Date.parse(voucher.expiresAt);
206
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now() || voucher.priceMicroUsdPerMillionTokens < price) {
207
+ return false;
208
+ }
209
+ return safeVerifyData(contractVoucherPayload(voucher), voucher.publicKey, voucher.signature);
210
+ }
211
+ /** Splits `path.Name` or `path.Name@version`. */
212
+ function contractRef(ref) {
213
+ const at = ref.indexOf('@');
214
+ const qualified = at === -1 ? ref : ref.slice(0, at);
215
+ const version = at === -1 ? undefined : ref.slice(at + 1);
216
+ const dot = qualified.lastIndexOf('.');
217
+ if (dot <= 0 || dot === qualified.length - 1 || version === '') {
218
+ return undefined;
219
+ }
220
+ return { path: qualified.slice(0, dot), name: qualified.slice(dot + 1), version };
221
+ }
222
+ /** A contract as `GET /v1/contracts` lists it. Its source is public, so callers can read what a call does. */
223
+ function contractView(contract) {
224
+ const { path, name, version, parameters, returnType, ownerOrganizationId, description, source, createdAt } = contract;
225
+ return { path, name, version, parameters, returnType, ownerOrganizationId, description, source, createdAt };
226
+ }
173
227
  /**
174
228
  * Encapsulates the routes
175
229
  * @param fastify Encapsulated Fastify Instance
@@ -262,7 +316,10 @@ const routes = async (fastify, options) => {
262
316
  type: parameter.type,
263
317
  description: parameter.description,
264
318
  })),
265
- paymentManifest: !fn.paymentManifest ? undefined : isInferenceManifest(fn.paymentManifest) ? {
319
+ paymentManifest: !fn.paymentManifest ? undefined : isContractManifest(fn.paymentManifest) ? {
320
+ priceMicroUsdPerMillionTokens: fn.paymentManifest.priceMicroUsdPerMillionTokens,
321
+ recipientOrganizationId: fn.paymentManifest.recipientOrganizationId,
322
+ } : isInferenceManifest(fn.paymentManifest) ? {
266
323
  inputCostMicroUsdPerMillionTokens: fn.paymentManifest.inputCostMicroUsdPerMillionTokens,
267
324
  outputCostMicroUsdPerMillionTokens: fn.paymentManifest.outputCostMicroUsdPerMillionTokens,
268
325
  recipientOrganizationId: fn.paymentManifest.recipientOrganizationId,
@@ -271,7 +328,7 @@ const routes = async (fastify, options) => {
271
328
  recipientOrganizationId: fn.paymentManifest.recipientOrganizationId,
272
329
  },
273
330
  restricted: fn.restricted || undefined,
274
- kind: isInferenceFunction(fn) ? 'inference' : undefined,
331
+ kind: isInferenceFunction(fn) || isContractFunction(fn) ? fn.kind : undefined,
275
332
  })),
276
333
  baseUrl: resource.baseUrl,
277
334
  })),
@@ -317,36 +374,28 @@ const routes = async (fastify, options) => {
317
374
  const caller = request.runCaller;
318
375
  const billed = caller.kind === 'client' && caller.organizationId !== options.service.config.organizationId;
319
376
  const runId = billed ? randomUUID() : undefined;
320
- let resourceInvoker = options.service.resourceInvoker;
321
377
  if (billed) {
322
378
  const authorization = await options.service.operatorClient.authorizeRun({ token: caller.token, runId: runId, source: runRequest.source });
323
379
  if (!authorization.ok) {
324
380
  return reply.status(authorization.status).send({ error: authorization.error });
325
381
  }
326
- resourceInvoker = options.service.resourceInvoker.forRun({ runId: runId });
327
382
  }
383
+ // The run token's claims, or null for an API-key run, so the name is always reserved.
384
+ const jwt = caller.kind === 'client' ? caller.payload : null;
385
+ // Contracts the script calls on this node see who pays for the run: the client's organization, or this node's own.
386
+ const { organizationId, publicKey } = options.service.config;
387
+ const runFor = { org: caller.kind === 'client' ? caller.organizationId : organizationId ?? null, node: publicKey };
388
+ const resourceInvoker = options.service.resourceInvoker.forScript({ runId, jwt, depth: 0, caller: runFor });
328
389
  // Nothing waits on settlement, so it doesn't delay the response.
329
- const settle = (outcome, tokens) => runId && setTimeout(async () => {
330
- try {
331
- if (outcome === 'capture') {
332
- await options.service.operatorClient.captureRun({ runId, tokens });
333
- }
334
- else {
335
- await options.service.operatorClient.releaseRun({ runId });
336
- }
337
- }
338
- catch (error) {
339
- fastify.log.error(error, `Failed to ${outcome} run ${runId}`);
340
- }
341
- }, 0);
390
+ const settle = (outcome, tokens) => runId
391
+ && settleRunLater(options.service.operatorClient, runId, outcome, tokens, (error, message) => fastify.log.error(error, message));
342
392
  const meter = new TokenMeter();
343
393
  try {
344
394
  const result = await interpretParsed(script, {
345
395
  resourceResolver: options.service.resourceResolver,
346
396
  resourceInvoker,
347
397
  meter,
348
- // The run token's claims, or null for an API-key run, so the name is always reserved.
349
- builtins: { jwt: caller.kind === 'client' ? caller.payload : null },
398
+ builtins: { jwt },
350
399
  });
351
400
  settle('capture', meter.tokens);
352
401
  return reply.status(200).send({ returnValue: result.returnValue ?? null, usage: { tokens: meter.tokens } });
@@ -442,6 +491,35 @@ const routes = async (fastify, options) => {
442
491
  return reply;
443
492
  }
444
493
  const { resource, resourceFunction } = call;
494
+ // A contract is always paid for with a contract voucher, since its paid calls are paid by its caller.
495
+ if (isContractFunction(resourceFunction)) {
496
+ const contract = await options.service.contractRepository.latest(resource.path, resource.name);
497
+ if (!contract) {
498
+ return reply.status(400).send({ error: 'Resource not found' });
499
+ }
500
+ if (!isContractVoucher(paymentVoucher)) {
501
+ return reply.status(402).send({ error: 'A contract voucher is required' });
502
+ }
503
+ const { organizationId, priceMicroUsdPerMillionTokens } = options.service.config;
504
+ if (!isValidContractVoucher(paymentVoucher, resourceRequest, organizationId, priceMicroUsdPerMillionTokens ?? 0)) {
505
+ return reply.status(400).send({ error: 'Invalid payment voucher' });
506
+ }
507
+ if (await options.service.grantService.isBlocked(paymentVoucher.publicKey)) {
508
+ return reply.status(403).send({ error: 'Caller is blocked' });
509
+ }
510
+ const depth = resourceRequest.contractDepth ?? 1;
511
+ if (depth > MAX_CONTRACT_DEPTH) {
512
+ return reply.status(400).send({ error: `Contracts may call each other at most ${MAX_CONTRACT_DEPTH} deep` });
513
+ }
514
+ const result = await options.service.contractService.serve(paymentVoucher, contract, resourceRequest.parameters, depth);
515
+ if ('refused' in result) {
516
+ return reply.status(result.refused.status).send({ error: result.refused.error });
517
+ }
518
+ return result;
519
+ }
520
+ if (isContractVoucher(paymentVoucher)) {
521
+ return reply.status(400).send({ error: 'Invalid payment voucher' });
522
+ }
445
523
  // Payment manifests are per function. A paid function is never run without a valid voucher.
446
524
  const paymentManifest = resourceFunction.paymentManifest;
447
525
  if (paymentManifest) {
@@ -478,6 +556,64 @@ const routes = async (fastify, options) => {
478
556
  return result;
479
557
  });
480
558
  // End Run Routes
559
+ // Start Contract Routes
560
+ /** The node's admin, or an organization's API client, from `requireRunAuthorization`. */
561
+ const contractCaller = (request) => {
562
+ const caller = request.runCaller;
563
+ return caller.kind === 'apiKey' ? caller : { kind: 'client', token: caller.token, organizationId: caller.organizationId };
564
+ };
565
+ /**
566
+ * Deploys a version of a contract, which becomes one of this node's resources. Another organization's
567
+ * API client pays the node's deploy fee from its run hold.
568
+ */
569
+ fastify.post('/contracts', { schema: { body: contractRequestSchema }, preValidation: requireRunAuthorization }, async (request, reply) => {
570
+ const { source, description } = request.body;
571
+ const result = await options.service.contractService.deploy(source, description, contractCaller(request));
572
+ if (!result.ok) {
573
+ return reply.status(result.status).send({ error: result.error });
574
+ }
575
+ return reply.status(201).send(result.contract);
576
+ });
577
+ /** Every version of every contract on this node, with its source. */
578
+ fastify.get('/contracts', { schema: { querystring: pageQuerySchema } }, async (request) => {
579
+ const { items, nextOffset } = await getPage(request.query, (offset, limit) => options.service.contractRepository.list(offset, limit));
580
+ return { contracts: items.map(contractView), nextOffset };
581
+ });
582
+ /**
583
+ * A contract's state variables, every scope, with values as JSON. Only its owner and this node's admin may
584
+ * read them: a contract shares its state with callers through what it returns.
585
+ */
586
+ fastify.get('/contracts/:ref/state', { preValidation: requireRunAuthorization }, async (request, reply) => {
587
+ const ref = contractRef(request.params.ref);
588
+ if (!ref || ref.version !== undefined) {
589
+ return reply.status(400).send({ error: 'Name a contract as path.Name: state belongs to every version' });
590
+ }
591
+ const result = await options.service.contractService.readState(ref.path, ref.name, contractCaller(request));
592
+ if (!result.ok) {
593
+ return reply.status(result.status).send({ error: result.error });
594
+ }
595
+ return {
596
+ variables: result.variables.map(({ variable, scope, value, updatedAt }) => ({
597
+ name: variable,
598
+ scope,
599
+ value: valueToJson(decodeValue(value)),
600
+ updatedAt,
601
+ })),
602
+ };
603
+ });
604
+ /** Deletes `path.Name@version`, or every version of `path.Name`. */
605
+ fastify.delete('/contracts/:ref', { preValidation: requireRunAuthorization }, async (request, reply) => {
606
+ const ref = contractRef(request.params.ref);
607
+ if (!ref) {
608
+ return reply.status(400).send({ error: 'Name a contract as path.Name or path.Name@version' });
609
+ }
610
+ const result = await options.service.contractService.delete(ref.path, ref.name, ref.version, contractCaller(request));
611
+ if (!result.ok) {
612
+ return reply.status(result.status).send({ error: result.error });
613
+ }
614
+ return { success: true };
615
+ });
616
+ // End Contract Routes
481
617
  // Start Grant Routes
482
618
  /** Issues a root grant to the request's signer, if the node's approval hook allows it. */
483
619
  fastify.post('/grants/request', {