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.
- package/dist/client/operatorClient.d.ts +10 -4
- package/dist/client/operatorClient.d.ts.map +1 -1
- package/dist/client/operatorClient.js +51 -7
- package/dist/crypto/utils.d.ts +9 -0
- package/dist/crypto/utils.d.ts.map +1 -1
- package/dist/crypto/utils.js +30 -1
- package/dist/db/db.d.ts +5 -4
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +88 -12
- package/dist/grant/grant.d.ts +132 -0
- package/dist/grant/grant.d.ts.map +1 -0
- package/dist/grant/grant.js +158 -0
- package/dist/grant/repository.d.ts +57 -0
- package/dist/grant/repository.d.ts.map +1 -0
- package/dist/grant/repository.js +105 -0
- package/dist/grant/service.d.ts +141 -0
- package/dist/grant/service.d.ts.map +1 -0
- package/dist/grant/service.js +431 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/peer/repository.d.ts +33 -2
- package/dist/peer/repository.d.ts.map +1 -1
- package/dist/peer/repository.js +43 -21
- package/dist/resource/invoker.d.ts +21 -9
- package/dist/resource/invoker.d.ts.map +1 -1
- package/dist/resource/invoker.js +69 -16
- package/dist/resource/repository.d.ts +47 -8
- package/dist/resource/repository.d.ts.map +1 -1
- package/dist/resource/repository.js +122 -67
- package/dist/routes.d.ts +8 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +303 -49
- package/dist/server.d.ts +62 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +119 -18
- package/dist/service.d.ts +24 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +9 -6
- package/dist/startup.d.ts +14 -0
- package/dist/startup.d.ts.map +1 -1
- package/dist/startup.js +90 -76
- package/dist/tests/client/operatorClient.test.d.ts +2 -0
- package/dist/tests/client/operatorClient.test.d.ts.map +1 -0
- package/dist/tests/client/operatorClient.test.js +96 -0
- package/dist/tests/db/db.test.d.ts +2 -0
- package/dist/tests/db/db.test.d.ts.map +1 -0
- package/dist/tests/db/db.test.js +64 -0
- package/dist/tests/grant/grant.test.d.ts +2 -0
- package/dist/tests/grant/grant.test.d.ts.map +1 -0
- package/dist/tests/grant/grant.test.js +133 -0
- package/dist/tests/grant/service.test.d.ts +2 -0
- package/dist/tests/grant/service.test.d.ts.map +1 -0
- package/dist/tests/grant/service.test.js +415 -0
- package/dist/tests/helpers.d.ts +13 -0
- package/dist/tests/helpers.d.ts.map +1 -0
- package/dist/tests/helpers.js +49 -0
- package/dist/tests/integration/network.test.d.ts +2 -0
- package/dist/tests/integration/network.test.d.ts.map +1 -0
- package/dist/tests/integration/network.test.js +188 -0
- package/dist/tests/peer/repository.test.d.ts +2 -0
- package/dist/tests/peer/repository.test.d.ts.map +1 -0
- package/dist/tests/peer/repository.test.js +50 -0
- package/dist/tests/resource/invoker.test.js +132 -5
- package/dist/tests/resource/repository.test.d.ts +2 -0
- package/dist/tests/resource/repository.test.d.ts.map +1 -0
- package/dist/tests/resource/repository.test.js +151 -0
- package/dist/tests/routes.test.d.ts +2 -0
- package/dist/tests/routes.test.d.ts.map +1 -0
- package/dist/tests/routes.test.js +565 -0
- package/dist/tests/server.test.d.ts +2 -0
- package/dist/tests/server.test.d.ts.map +1 -0
- package/dist/tests/server.test.js +37 -0
- package/dist/tests/startup.test.d.ts +2 -0
- package/dist/tests/startup.test.d.ts.map +1 -0
- package/dist/tests/startup.test.js +75 -0
- package/package.json +2 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"invoker.d.ts","sourceRoot":"","sources":["../../src/resource/invoker.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,6BAA6B,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAEzF,OAAO,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAErD,MAAM,WAAW,cAAc;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACrB;AAED;;;;GAIG;AACH,wBAAgB,
|
|
1
|
+
{"version":3,"file":"invoker.d.ts","sourceRoot":"","sources":["../../src/resource/invoker.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,6BAA6B,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAEzF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAErD,MAAM,WAAW,cAAc;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;CACrB;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC1B,YAAY,EAAE,MAAM,EACpB,YAAY,EAAE,MAAM,EACpB,YAAY,EAAE,MAAM,EACpB,UAAU,EAAE,6BAA6B,EAAE,GAC5C,MAAM,CAER;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,IAAI,CAAC,cAAc,EAAE,WAAW,CAAC;;;;;;;;EAG/E;AAED,eAAO,MAAM,2BAA2B,IAAI,CAAC;AAC7C,eAAO,MAAM,6BAA6B,MAAM,CAAC;AACjD,wEAAwE;AACxE,eAAO,MAAM,0BAA0B,QAAS,CAAC;AA2BjD,qBAAa,0BAA2B,YAAW,eAAe;IAGlD,OAAO,CAAC,QAAQ,CAAC,kBAAkB;IAAsB,OAAO,CAAC,QAAQ,CAAC,iBAAiB;IAAU,OAAO,CAAC,QAAQ,CAAC,SAAS;IAAU,OAAO,CAAC,QAAQ,CAAC,UAAU;IAAU,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC;IADxN,2EAA2E;IAC3E,YAA6B,kBAAkB,EAAE,kBAAkB,EAAmB,iBAAiB,EAAE,MAAM,EAAmB,SAAS,EAAE,MAAM,EAAmB,UAAU,EAAE,MAAM,EAAmB,YAAY,CAAC,EAAE,YAAY,YAAA,EAAI;IAC1O;;;;;;;;;;;OAWG;IACG,MAAM,CAAC,QAAQ,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,6BAA6B,EAAE,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,GAAG,CAAC;QAAC,KAAK,EAAE;YAAE,OAAO,EAAE,MAAM,CAAA;SAAE,GAAG,IAAI,CAAA;KAAC,CAAC,CAgE7J;IAED,oBAAoB,CAAC,gBAAgB,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,6BAA6B,EAAE,GAAG,cAAc,CAQ5L;CACJ"}
|
package/dist/resource/invoker.js
CHANGED
|
@@ -1,27 +1,67 @@
|
|
|
1
|
-
import { signData } from "../crypto/utils.js";
|
|
1
|
+
import { hashData, signData } from "../crypto/utils.js";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Hash binding a voucher to one function call. Providers recompute it from the
|
|
4
|
+
* request they receive and compare it to `voucher.invocationHash`.
|
|
5
|
+
* Key order is part of the hashed bytes.
|
|
6
6
|
*/
|
|
7
|
-
export function
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
export function invocationHash(resourcePath, resourceName, functionName, parameters) {
|
|
8
|
+
return hashData({ resourcePath, resourceName, functionName, parameters });
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The object `createPaymentVoucher` signs: every voucher field except `signature`.
|
|
12
|
+
* Key order is part of the signed bytes.
|
|
13
|
+
*/
|
|
14
|
+
export function paymentVoucherPayload(voucher) {
|
|
15
|
+
const { requestId, invocationId, amount, recipientAddress, expiresAt, invocationHash, publicKey } = voucher;
|
|
16
|
+
return { requestId, invocationId, amount, recipientAddress, expiresAt, invocationHash, publicKey };
|
|
17
|
+
}
|
|
18
|
+
export const EXTERNAL_INVOKE_MAX_RETRIES = 3;
|
|
19
|
+
export const EXTERNAL_INVOKE_BASE_DELAY_MS = 500;
|
|
20
|
+
/** Per attempt. A timed-out attempt is retried like a network error. */
|
|
21
|
+
export const EXTERNAL_INVOKE_TIMEOUT_MS = 30_000;
|
|
22
|
+
/**
|
|
23
|
+
* POST to a remote resource, retrying network errors, 429s, and 5xx responses up to
|
|
24
|
+
* `EXTERNAL_INVOKE_MAX_RETRIES` times with exponential backoff (500ms, 1s, 2s).
|
|
25
|
+
* Other 4xx responses are returned immediately since retrying won't change them.
|
|
26
|
+
* Throws the last network error if every attempt fails to connect.
|
|
27
|
+
*/
|
|
28
|
+
async function fetchWithRetry(url, init) {
|
|
29
|
+
for (let attempt = 0;; attempt++) {
|
|
30
|
+
try {
|
|
31
|
+
const response = await fetch(url, { ...init(), signal: AbortSignal.timeout(EXTERNAL_INVOKE_TIMEOUT_MS) });
|
|
32
|
+
if (response.status !== 429 && response.status < 500) {
|
|
33
|
+
return response;
|
|
34
|
+
}
|
|
35
|
+
if (attempt >= EXTERNAL_INVOKE_MAX_RETRIES) {
|
|
36
|
+
return response;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (attempt >= EXTERNAL_INVOKE_MAX_RETRIES) {
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
await new Promise(resolve => setTimeout(resolve, EXTERNAL_INVOKE_BASE_DELAY_MS * 2 ** attempt));
|
|
45
|
+
}
|
|
10
46
|
}
|
|
11
47
|
export class CombinationResourceInvoker {
|
|
12
48
|
resourceRepository;
|
|
13
49
|
costLimitLamports;
|
|
14
50
|
publicKey;
|
|
15
51
|
privateKey;
|
|
16
|
-
|
|
52
|
+
grantService;
|
|
53
|
+
/** Without a `grantService`, calls to restricted remote functions fail. */
|
|
54
|
+
constructor(resourceRepository, costLimitLamports, publicKey, privateKey, grantService) {
|
|
17
55
|
this.resourceRepository = resourceRepository;
|
|
18
56
|
this.costLimitLamports = costLimitLamports;
|
|
19
57
|
this.publicKey = publicKey;
|
|
20
58
|
this.privateKey = privateKey;
|
|
59
|
+
this.grantService = grantService;
|
|
21
60
|
}
|
|
22
61
|
/**
|
|
23
62
|
* Invoke a function on a resource.
|
|
24
63
|
* If the resource has a baseUrl, it will be invoked as a remote function.
|
|
64
|
+
* If the function is restricted, it attaches an authorization from a held grant.
|
|
25
65
|
* If the resource has a paymentManifest, it will create a payment voucher and pass it to the resource.
|
|
26
66
|
* Otherwise, it will be invoked as a local function.
|
|
27
67
|
*
|
|
@@ -46,24 +86,36 @@ export class CombinationResourceInvoker {
|
|
|
46
86
|
if (!resourceFunction) {
|
|
47
87
|
throw new Error(`Function ${functionName} not found in resource ${resource.name}`);
|
|
48
88
|
}
|
|
89
|
+
const target = { resourcePath: resource.path, resourceName: resource.name, functionName, parameters };
|
|
90
|
+
// Checked before the voucher, so a call this node can't make never signs one.
|
|
91
|
+
const chain = resourceFunction.restricted ? await this.grantService?.chainFor(url, target) : undefined;
|
|
92
|
+
if (resourceFunction.restricted && !chain) {
|
|
93
|
+
return { value: null, error: { message: `Failed to invoke ${resource.name}.${functionName}: it requires a grant from ${url}, and this node holds none that covers the call` } };
|
|
94
|
+
}
|
|
49
95
|
let paymentVoucher;
|
|
50
96
|
if (resourceFunction.paymentManifest) {
|
|
51
97
|
console.log(`Resource function ${resource.name}.${functionName} has a payment manifest, creating payment voucher...`);
|
|
52
98
|
if (resourceFunction.paymentManifest.costLamports > this.costLimitLamports) {
|
|
53
99
|
throw new Error(`Function ${functionName} cost ${resourceFunction.paymentManifest.costLamports} lamports, which exceeds the cost limit of ${this.costLimitLamports} lamports`);
|
|
54
100
|
}
|
|
55
|
-
paymentVoucher = this.createPaymentVoucher(resourceFunction.paymentManifest.recipientAddress, resourceFunction.paymentManifest.costLamports, resource.path, resource.name, parameters);
|
|
101
|
+
paymentVoucher = this.createPaymentVoucher(resourceFunction.paymentManifest.recipientAddress, resourceFunction.paymentManifest.costLamports, resource.path, resource.name, functionName, parameters);
|
|
56
102
|
requestBody.paymentVoucher = paymentVoucher;
|
|
57
103
|
}
|
|
58
|
-
|
|
104
|
+
// The voucher is created once and reused on every attempt, so the operator's
|
|
105
|
+
// requestId/invocationId check prevents a retried call from being settled twice.
|
|
106
|
+
// The authorization is signed per attempt, since the provider refuses a reused nonce.
|
|
107
|
+
const result = await fetchWithRetry(`${url}/v1/run/resource`, () => ({
|
|
59
108
|
method: 'POST',
|
|
60
|
-
body: JSON.stringify(requestBody),
|
|
109
|
+
body: JSON.stringify(chain && this.grantService ? { ...requestBody, authorization: this.grantService.authorize(chain, target) } : requestBody),
|
|
61
110
|
headers: {
|
|
62
111
|
'Content-Type': 'application/json',
|
|
63
112
|
},
|
|
64
|
-
});
|
|
113
|
+
}));
|
|
65
114
|
if (!result.ok) {
|
|
66
|
-
|
|
115
|
+
// The provider's JSON error is more useful than the status text, which can be empty.
|
|
116
|
+
const detail = await result.json().then((body) => body?.error, () => undefined);
|
|
117
|
+
const reason = typeof detail === 'string' ? detail : `${result.status} ${result.statusText}`.trim();
|
|
118
|
+
return { value: null, error: { message: `Failed to invoke ${resource.name}.${functionName}: ${reason}` } };
|
|
67
119
|
}
|
|
68
120
|
const data = await result.json();
|
|
69
121
|
return { value: data.value, error: data.error };
|
|
@@ -77,12 +129,13 @@ export class CombinationResourceInvoker {
|
|
|
77
129
|
return { value: result, error: null };
|
|
78
130
|
}
|
|
79
131
|
}
|
|
80
|
-
createPaymentVoucher(recipientAddress, amount, resourcePath, resourceName, parameters) {
|
|
132
|
+
createPaymentVoucher(recipientAddress, amount, resourcePath, resourceName, functionName, parameters) {
|
|
81
133
|
const requestId = crypto.randomUUID();
|
|
82
134
|
const invocationId = crypto.randomUUID();
|
|
83
135
|
// Expire in 2 minutes
|
|
84
136
|
const expiresAt = new Date(Date.now() + 1000 * 60 * 2).toISOString();
|
|
85
|
-
const
|
|
86
|
-
|
|
137
|
+
const hash = invocationHash(resourcePath, resourceName, functionName, parameters);
|
|
138
|
+
const signature = signData(paymentVoucherPayload({ requestId, invocationId, amount, recipientAddress, expiresAt, invocationHash: hash, publicKey: this.publicKey }), this.privateKey);
|
|
139
|
+
return { requestId, invocationId, amount, recipientAddress, signature, expiresAt, invocationHash: hash, publicKey: this.publicKey };
|
|
87
140
|
}
|
|
88
141
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Resource, ResourceFunction, ResourceFunctionParameter } from "requestscript";
|
|
2
2
|
import { Database } from "sqlite";
|
|
3
|
-
import type {
|
|
3
|
+
import type { StoredPeer } from "../peer/repository.js";
|
|
4
4
|
export interface StellariumResourceFunctionPaymentManifest {
|
|
5
5
|
id?: number;
|
|
6
6
|
costLamports: number;
|
|
@@ -9,27 +9,66 @@ export interface StellariumResourceFunctionPaymentManifest {
|
|
|
9
9
|
export interface StellariumResourceFunction extends ResourceFunction {
|
|
10
10
|
id?: number;
|
|
11
11
|
paymentManifest?: StellariumResourceFunctionPaymentManifest;
|
|
12
|
+
/** Callers need a grant from the hosting node. Announced to peers so callers can tell. */
|
|
13
|
+
restricted?: boolean;
|
|
12
14
|
}
|
|
13
15
|
export interface StellariumResource extends Resource {
|
|
14
16
|
id: number;
|
|
15
17
|
baseUrl: string;
|
|
16
18
|
functions: StellariumResourceFunction[];
|
|
17
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* The object a node signs to broadcast its resources. Key order is part of the signed bytes.
|
|
22
|
+
* Functions' `exec` implementations are dropped by JSON serialization on both ends.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resourceAnnouncementPayload(resources: StellariumResource[], expiry: string): {
|
|
25
|
+
resources: StellariumResource[];
|
|
26
|
+
expiry: string;
|
|
27
|
+
};
|
|
28
|
+
export type SaveAllResult = {
|
|
29
|
+
ok: true;
|
|
30
|
+
rejected: string[];
|
|
31
|
+
} | {
|
|
32
|
+
ok: false;
|
|
33
|
+
};
|
|
18
34
|
export declare class ResourceRepository {
|
|
19
35
|
private readonly db;
|
|
20
36
|
private resources;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
37
|
+
private readonly baseUrl;
|
|
38
|
+
private writes;
|
|
39
|
+
constructor(db: Database, resources?: StellariumResource[], baseUrl?: string);
|
|
40
|
+
private serialize;
|
|
41
|
+
private transaction;
|
|
42
|
+
private isInternal;
|
|
43
|
+
/**
|
|
44
|
+
* Saves a resource learned from the starting peer, if it isn't known yet. Resources hosted
|
|
45
|
+
* by this node and resources already known are left alone, so startup can run on every boot.
|
|
46
|
+
*/
|
|
47
|
+
learn(resource: StellariumResource): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Saves the resources a peer broadcast, after checking the peer signed them.
|
|
50
|
+
* A peer may only publish resources served from its own base URL, and may not replace a
|
|
51
|
+
* resource owned by another peer or hosted by this node. Those are returned in `rejected`.
|
|
52
|
+
* Accepted resources are replaced as a whole, so added or removed functions,
|
|
53
|
+
* parameters and payment manifests all take effect.
|
|
54
|
+
*/
|
|
55
|
+
saveAll(resources: StellariumResource[], peer: StoredPeer, signature: string, expiry: string): Promise<SaveAllResult>;
|
|
56
|
+
private insert;
|
|
57
|
+
private insertFunctions;
|
|
58
|
+
private deleteFunctions;
|
|
59
|
+
getInternalResource(path: string, name: string): Promise<StellariumResource | undefined>;
|
|
26
60
|
getResource(key: string): Promise<Resource | undefined>;
|
|
27
61
|
getInternalResources(): Promise<StellariumResource[]>;
|
|
28
|
-
|
|
62
|
+
/** Learned resources in insertion order. A negative `limit` means no limit. */
|
|
63
|
+
getExternalResources(offset?: number, limit?: number): Promise<StellariumResource[]>;
|
|
29
64
|
getResourceFunction(resourcePath: string, resourceName: string, functionName: string): Promise<StellariumResourceFunction | undefined>;
|
|
30
65
|
getResourceFunctions(resourceId: number): Promise<StellariumResourceFunction[]>;
|
|
31
66
|
getResourceParameters(resourceFunctionId: number): Promise<ResourceFunctionParameter[]>;
|
|
32
67
|
getResourceFunctionPaymentManifest(resourceFunctionId: number): Promise<StellariumResourceFunctionPaymentManifest | undefined>;
|
|
33
|
-
|
|
68
|
+
/**
|
|
69
|
+
* One page of every resource this node knows: the ones it hosts first, then learned ones.
|
|
70
|
+
* Both are in a fixed order, so offsets stay stable as resources are learned.
|
|
71
|
+
*/
|
|
72
|
+
getResources(offset: number, limit: number): Promise<StellariumResource[]>;
|
|
34
73
|
}
|
|
35
74
|
//# sourceMappingURL=repository.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"repository.d.ts","sourceRoot":"","sources":["../../src/resource/repository.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,gBAAgB,EAAE,yBAAyB,EAAE,MAAM,eAAe,CAAC;AACtF,OAAO,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AAClC,OAAO,KAAK,EAAE,
|
|
1
|
+
{"version":3,"file":"repository.d.ts","sourceRoot":"","sources":["../../src/resource/repository.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,gBAAgB,EAAE,yBAAyB,EAAE,MAAM,eAAe,CAAC;AACtF,OAAO,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AAClC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAExD,MAAM,WAAW,yCAAyC;IACtD,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,0BAA2B,SAAQ,gBAAgB;IAChE,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,eAAe,CAAC,EAAE,yCAAyC,CAAC;IAC5D,0FAA0F;IAC1F,UAAU,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,kBAAmB,SAAQ,QAAQ;IAChD,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,0BAA0B,EAAE,CAAC;CAC3C;AAED;;;GAGG;AACH,wBAAgB,2BAA2B,CAAC,SAAS,EAAE,kBAAkB,EAAE,EAAE,MAAM,EAAE,MAAM;;;EAE1F;AAED,MAAM,MAAM,aAAa,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAA;CAAE,CAAC;AAY7E,qBAAa,kBAAkB;IAIf,OAAO,CAAC,QAAQ,CAAC,EAAE;IAAY,OAAO,CAAC,SAAS;IAA6B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAFjH,OAAO,CAAC,MAAM,CAAuC;IAErD,YAA6B,EAAE,EAAE,QAAQ,EAAU,SAAS,GAAE,kBAAkB,EAAO,EAAmB,OAAO,GAAE,MAAW,EAAI;IAElI,OAAO,CAAC,SAAS;IAMjB,OAAO,CAAC,WAAW;IAcnB,OAAO,CAAC,UAAU;IAIlB;;;OAGG;IACG,KAAK,CAAC,QAAQ,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAUvD;IAED;;;;;;OAMG;IACG,OAAO,CAAC,SAAS,EAAE,kBAAkB,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAoC1H;YAEa,MAAM;YAcN,eAAe;YA0Bf,eAAe;IAOvB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC,CAE7F;IAEK,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,SAAS,CAAC,CA6B5D;IAEK,oBAAoB,IAAI,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAE1D;IAED,+EAA+E;IACzE,oBAAoB,CAAC,MAAM,SAAI,EAAE,KAAK,SAAK,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAchF;IAEK,mBAAmB,CAAC,YAAY,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,0BAA0B,GAAG,SAAS,CAAC,CAoB3I;IAEK,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,0BAA0B,EAAE,CAAC,CAWpF;IAEK,qBAAqB,CAAC,kBAAkB,EAAE,MAAM,GAAG,OAAO,CAAC,yBAAyB,EAAE,CAAC,CAK5F;IAEK,kCAAkC,CAAC,kBAAkB,EAAE,MAAM,GAAG,OAAO,CAAC,yCAAyC,GAAG,SAAS,CAAC,CAUnI;IAED;;;OAGG;IACG,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAQ/E;CACJ"}
|
|
@@ -1,20 +1,108 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isFreshExpiry, safeVerifyData } from "../crypto/utils.js";
|
|
2
|
+
/**
|
|
3
|
+
* The object a node signs to broadcast its resources. Key order is part of the signed bytes.
|
|
4
|
+
* Functions' `exec` implementations are dropped by JSON serialization on both ends.
|
|
5
|
+
*/
|
|
6
|
+
export function resourceAnnouncementPayload(resources, expiry) {
|
|
7
|
+
return { resources, expiry };
|
|
8
|
+
}
|
|
9
|
+
const resourceKey = (resource) => `${resource.path}.${resource.name}`;
|
|
2
10
|
export class ResourceRepository {
|
|
3
11
|
db;
|
|
4
12
|
resources;
|
|
5
|
-
|
|
13
|
+
baseUrl;
|
|
14
|
+
// The connection is shared, so transactions are serialized to keep them from nesting.
|
|
15
|
+
writes = Promise.resolve();
|
|
16
|
+
constructor(db, resources = [], baseUrl = '') {
|
|
6
17
|
this.db = db;
|
|
7
18
|
this.resources = resources;
|
|
19
|
+
this.baseUrl = baseUrl;
|
|
20
|
+
}
|
|
21
|
+
serialize(work) {
|
|
22
|
+
const run = this.writes.then(work, work);
|
|
23
|
+
this.writes = run.catch(() => undefined);
|
|
24
|
+
return run;
|
|
8
25
|
}
|
|
9
|
-
|
|
26
|
+
transaction(work) {
|
|
27
|
+
return this.serialize(async () => {
|
|
28
|
+
await this.db.exec('BEGIN IMMEDIATE');
|
|
29
|
+
try {
|
|
30
|
+
const result = await work();
|
|
31
|
+
await this.db.exec('COMMIT');
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
await this.db.exec('ROLLBACK');
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
isInternal(resource) {
|
|
41
|
+
return this.resources.some(r => r.path === resource.path && r.name === resource.name);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Saves a resource learned from the starting peer, if it isn't known yet. Resources hosted
|
|
45
|
+
* by this node and resources already known are left alone, so startup can run on every boot.
|
|
46
|
+
*/
|
|
47
|
+
async learn(resource) {
|
|
48
|
+
if (this.isInternal(resource) || resource.baseUrl === this.baseUrl) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
await this.transaction(async () => {
|
|
52
|
+
const existing = await this.db.get('SELECT id FROM resource WHERE path = ? AND name = ?', [resource.path, resource.name]);
|
|
53
|
+
if (!existing) {
|
|
54
|
+
await this.insert(resource, null);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Saves the resources a peer broadcast, after checking the peer signed them.
|
|
60
|
+
* A peer may only publish resources served from its own base URL, and may not replace a
|
|
61
|
+
* resource owned by another peer or hosted by this node. Those are returned in `rejected`.
|
|
62
|
+
* Accepted resources are replaced as a whole, so added or removed functions,
|
|
63
|
+
* parameters and payment manifests all take effect.
|
|
64
|
+
*/
|
|
65
|
+
async saveAll(resources, peer, signature, expiry) {
|
|
66
|
+
if (!Array.isArray(resources) || !isFreshExpiry(expiry) || !safeVerifyData(resourceAnnouncementPayload(resources, expiry), peer.publicKey, signature)) {
|
|
67
|
+
return { ok: false };
|
|
68
|
+
}
|
|
69
|
+
const rejected = [];
|
|
70
|
+
await this.transaction(async () => {
|
|
71
|
+
for (const resource of resources) {
|
|
72
|
+
if (resource.baseUrl !== peer.baseUrl || this.isInternal(resource)) {
|
|
73
|
+
rejected.push(resourceKey(resource));
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const existing = await this.db.get('SELECT * FROM resource WHERE path = ? AND name = ?', [resource.path, resource.name]);
|
|
77
|
+
if (!existing) {
|
|
78
|
+
await this.insert(resource, peer.id);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
// Unowned rows came from the starting peer; the peer they point at may claim them.
|
|
82
|
+
const ownedByPeer = existing.peer_id === null ? existing.base_url === peer.baseUrl : existing.peer_id === peer.id;
|
|
83
|
+
if (!ownedByPeer) {
|
|
84
|
+
rejected.push(resourceKey(resource));
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
await this.deleteFunctions(existing.id);
|
|
88
|
+
await this.db.run('UPDATE resource SET base_url = ?, peer_id = ?, updated_at = ? WHERE id = ?', [resource.baseUrl, peer.id, new Date().toISOString(), existing.id]);
|
|
89
|
+
await this.insertFunctions(existing.id, resource.functions);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
return { ok: true, rejected };
|
|
93
|
+
}
|
|
94
|
+
async insert(resource, peerId) {
|
|
10
95
|
const createdAt = new Date().toISOString();
|
|
11
|
-
const result = await this.db.run('INSERT INTO resource (path, name, base_url, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', [resource.path, resource.name, resource.baseUrl, createdAt, createdAt]);
|
|
12
|
-
if (!result.changes) {
|
|
96
|
+
const result = await this.db.run('INSERT INTO resource (path, name, base_url, peer_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', [resource.path, resource.name, resource.baseUrl, peerId, createdAt, createdAt]);
|
|
97
|
+
if (!result.changes || result.lastID === undefined) {
|
|
13
98
|
throw new Error('Failed to create resource');
|
|
14
99
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
100
|
+
await this.insertFunctions(result.lastID, resource.functions);
|
|
101
|
+
}
|
|
102
|
+
async insertFunctions(resourceId, functions) {
|
|
103
|
+
const createdAt = new Date().toISOString();
|
|
104
|
+
for (const fn of functions) {
|
|
105
|
+
const functionResult = await this.db.run('INSERT INTO resource_function (resource_id, name, return_type, restricted, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', [resourceId, fn.name, fn.returnType, fn.restricted === true ? 1 : 0, createdAt, createdAt]);
|
|
18
106
|
const functionId = functionResult.lastID;
|
|
19
107
|
for (const parameter of fn.parameters) {
|
|
20
108
|
await this.db.run('INSERT INTO resource_function_parameter (resource_function_id, name, parameter_type, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', [functionId, parameter.name, parameter.type, createdAt, createdAt]);
|
|
@@ -24,45 +112,17 @@ export class ResourceRepository {
|
|
|
24
112
|
}
|
|
25
113
|
}
|
|
26
114
|
}
|
|
27
|
-
async
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const existingResource = await this.db.get('SELECT * FROM resource WHERE path = ? AND name = ?', [resource.path, resource.name]);
|
|
33
|
-
// TODO: A signature must accompany the resource to ensure the base url change is only made by the owner of the resource.
|
|
34
|
-
// This will avoid MITM attacks.
|
|
35
|
-
if (existingResource) {
|
|
36
|
-
await this.db.run('UPDATE resource SET base_url = ?, updated_at = ? WHERE path = ? AND name = ?', [resource.baseUrl, new Date().toISOString(), resource.path, resource.name]);
|
|
37
|
-
for (const fn of resource.functions) {
|
|
38
|
-
const updatedAt = new Date().toISOString();
|
|
39
|
-
await this.db.run('UPDATE resource_function SET return_type = ?, updated_at = ? WHERE resource_id = ? AND name = ?', [fn.returnType, updatedAt, existingResource.id, fn.name]);
|
|
40
|
-
const existingFn = await this.db.get('SELECT id FROM resource_function WHERE resource_id = ? AND name = ?', [existingResource.id, fn.name]);
|
|
41
|
-
if (!existingFn) {
|
|
42
|
-
continue;
|
|
43
|
-
}
|
|
44
|
-
for (const parameter of fn.parameters) {
|
|
45
|
-
await this.db.run('UPDATE resource_function_parameter SET parameter_type = ? WHERE resource_function_id = ? AND name = ?', [parameter.type, existingFn.id, parameter.name]);
|
|
46
|
-
}
|
|
47
|
-
if (fn.paymentManifest) {
|
|
48
|
-
await this.db.run('UPDATE resource_function_payment_manifest SET cost_lamports = ?, recipient_address = ?, updated_at = ? WHERE resource_function_id = ?', [fn.paymentManifest.costLamports, fn.paymentManifest.recipientAddress, updatedAt, existingFn.id]);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
else {
|
|
53
|
-
await this.create(resource);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return true;
|
|
57
|
-
}
|
|
58
|
-
async getResourceByName(name) {
|
|
59
|
-
return this.resources.find(r => r.name === name);
|
|
115
|
+
async deleteFunctions(resourceId) {
|
|
116
|
+
const functionIds = 'SELECT id FROM resource_function WHERE resource_id = ?';
|
|
117
|
+
await this.db.run(`DELETE FROM resource_function_payment_manifest WHERE resource_function_id IN (${functionIds})`, [resourceId]);
|
|
118
|
+
await this.db.run(`DELETE FROM resource_function_parameter WHERE resource_function_id IN (${functionIds})`, [resourceId]);
|
|
119
|
+
await this.db.run('DELETE FROM resource_function WHERE resource_id = ?', [resourceId]);
|
|
60
120
|
}
|
|
61
121
|
async getInternalResource(path, name) {
|
|
62
122
|
return this.resources.find(r => r.path === path && r.name === name);
|
|
63
123
|
}
|
|
64
124
|
async getResource(key) {
|
|
65
|
-
const resource = this.resources.find(r =>
|
|
125
|
+
const resource = this.resources.find(r => resourceKey(r) === key);
|
|
66
126
|
if (resource) {
|
|
67
127
|
return resource;
|
|
68
128
|
}
|
|
@@ -74,33 +134,22 @@ export class ResourceRepository {
|
|
|
74
134
|
if (!result) {
|
|
75
135
|
return undefined;
|
|
76
136
|
}
|
|
77
|
-
const resourceFunctions = await this.db.all('SELECT * FROM resource_function WHERE resource_id = ?', [result.id]);
|
|
78
137
|
return {
|
|
79
138
|
path: result.path,
|
|
80
139
|
name: result.name,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
returnType: f.return_type,
|
|
84
|
-
parameters: await this.getResourceParameters(f.id),
|
|
85
|
-
// The ExternalResourceInvoker will handle the execution of the function
|
|
86
|
-
exec: async () => {
|
|
87
|
-
return void 0;
|
|
88
|
-
}
|
|
89
|
-
}))),
|
|
140
|
+
// The CombinationResourceInvoker forwards calls on these functions to `metadata.baseUrl`.
|
|
141
|
+
functions: await this.getResourceFunctions(result.id),
|
|
90
142
|
metadata: {
|
|
91
143
|
baseUrl: result.base_url,
|
|
92
|
-
paymentManifest: await this.getResourceFunctionPaymentManifest(result.id),
|
|
93
144
|
}
|
|
94
145
|
};
|
|
95
146
|
}
|
|
96
147
|
async getInternalResources() {
|
|
97
|
-
return this.resources.map(r => ({ ...r, baseUrl:
|
|
148
|
+
return this.resources.map(r => ({ ...r, baseUrl: this.baseUrl }));
|
|
98
149
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return [];
|
|
103
|
-
}
|
|
150
|
+
/** Learned resources in insertion order. A negative `limit` means no limit. */
|
|
151
|
+
async getExternalResources(offset = 0, limit = -1) {
|
|
152
|
+
const results = await this.db.all('SELECT * FROM resource ORDER BY id LIMIT ? OFFSET ?', [limit, offset]);
|
|
104
153
|
return Promise.all(results.map(async (r) => ({
|
|
105
154
|
id: r.id,
|
|
106
155
|
path: r.path,
|
|
@@ -126,6 +175,7 @@ export class ResourceRepository {
|
|
|
126
175
|
parameters: await this.getResourceParameters(fn.id),
|
|
127
176
|
exec: async () => undefined,
|
|
128
177
|
paymentManifest: await this.getResourceFunctionPaymentManifest(fn.id),
|
|
178
|
+
restricted: fn.restricted === 1 || undefined,
|
|
129
179
|
};
|
|
130
180
|
}
|
|
131
181
|
async getResourceFunctions(resourceId) {
|
|
@@ -137,10 +187,11 @@ export class ResourceRepository {
|
|
|
137
187
|
parameters: await this.getResourceParameters(f.id),
|
|
138
188
|
exec: async () => undefined,
|
|
139
189
|
paymentManifest: await this.getResourceFunctionPaymentManifest(f.id),
|
|
190
|
+
restricted: f.restricted === 1 || undefined,
|
|
140
191
|
})));
|
|
141
192
|
}
|
|
142
193
|
async getResourceParameters(resourceFunctionId) {
|
|
143
|
-
return (await this.db.all('SELECT * FROM resource_function_parameter WHERE resource_function_id = ?', [resourceFunctionId])).map(p => ({
|
|
194
|
+
return (await this.db.all('SELECT * FROM resource_function_parameter WHERE resource_function_id = ? ORDER BY id', [resourceFunctionId])).map(p => ({
|
|
144
195
|
name: p.name,
|
|
145
196
|
type: p.parameter_type,
|
|
146
197
|
}));
|
|
@@ -156,13 +207,17 @@ export class ResourceRepository {
|
|
|
156
207
|
recipientAddress: result.recipient_address,
|
|
157
208
|
};
|
|
158
209
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
210
|
+
/**
|
|
211
|
+
* One page of every resource this node knows: the ones it hosts first, then learned ones.
|
|
212
|
+
* Both are in a fixed order, so offsets stay stable as resources are learned.
|
|
213
|
+
*/
|
|
214
|
+
async getResources(offset, limit) {
|
|
215
|
+
const internal = (await this.getInternalResources()).slice(offset, offset + limit);
|
|
216
|
+
const remaining = limit - internal.length;
|
|
217
|
+
if (remaining <= 0) {
|
|
218
|
+
return internal;
|
|
164
219
|
}
|
|
165
|
-
resources.
|
|
166
|
-
return
|
|
220
|
+
const externalOffset = Math.max(0, offset - this.resources.length);
|
|
221
|
+
return [...internal, ...await this.getExternalResources(externalOffset, remaining)];
|
|
167
222
|
}
|
|
168
223
|
}
|
package/dist/routes.d.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import type { FastifyPluginAsync } from 'fastify';
|
|
2
2
|
import type { Service } from './service.js';
|
|
3
|
+
export interface RateLimitConfig {
|
|
4
|
+
max: number;
|
|
5
|
+
/** Milliseconds, or a string such as '1 minute'. */
|
|
6
|
+
timeWindow: number | string;
|
|
7
|
+
}
|
|
3
8
|
interface RouteOptions {
|
|
4
9
|
service: Service;
|
|
10
|
+
rateLimit?: RateLimitConfig;
|
|
5
11
|
}
|
|
12
|
+
export declare const DEFAULT_PAGE_SIZE = 100;
|
|
13
|
+
export declare const MAX_PAGE_SIZE = 500;
|
|
6
14
|
/**
|
|
7
15
|
* Encapsulates the routes
|
|
8
16
|
* @param fastify Encapsulated Fastify Instance
|
package/dist/routes.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAgC,MAAM,SAAS,CAAC;AAKhF,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;AAiDD,eAAO,MAAM,iBAAiB,MAAM,CAAC;AACrC,eAAO,MAAM,aAAa,MAAM,CAAC;AAsHjC;;;;GAIG;AACH,QAAA,MAAM,MAAM,EAAE,kBAAkB,CAAC,YAAY,CA8R5C,CAAC;eAGa,MAAM"}
|