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.
- 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 +93 -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 +7 -2
- 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 +56 -9
- package/dist/resource/repository.d.ts.map +1 -1
- package/dist/resource/repository.js +140 -68
- package/dist/routes.d.ts +8 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +310 -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 +182 -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
package/dist/server.js
CHANGED
|
@@ -1,32 +1,133 @@
|
|
|
1
1
|
import 'dotenv/config';
|
|
2
|
-
import {
|
|
2
|
+
import { DEFAULT_DATABASE_PATH, openDatabase } from './db/db.js';
|
|
3
3
|
import routes from './routes.js';
|
|
4
4
|
import { createService } from './service.js';
|
|
5
5
|
import { StartupService } from './startup.js';
|
|
6
|
+
export const DEFAULT_COST_LIMIT_LAMPORTS = 10_000;
|
|
7
|
+
export const DEFAULT_GRANT_REFRESH_INTERVAL_MS = 1000 * 60 * 5;
|
|
8
|
+
function required(name, value) {
|
|
9
|
+
if (!value) {
|
|
10
|
+
throw new Error(`${name} is required: pass it to StellariumNode.start or set it in the environment`);
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
function hostsPaidFunctions(resources) {
|
|
15
|
+
return resources.some(resource => resource.functions.some(fn => fn.paymentManifest));
|
|
16
|
+
}
|
|
17
|
+
export function resolveConfig(options, resources = []) {
|
|
18
|
+
const costLimit = options.costLimitLamports ?? (process.env.COST_LIMIT_LAMPORTS ? Number(process.env.COST_LIMIT_LAMPORTS) : DEFAULT_COST_LIMIT_LAMPORTS);
|
|
19
|
+
if (!Number.isSafeInteger(costLimit) || costLimit < 0) {
|
|
20
|
+
throw new Error('costLimitLamports must be a nonnegative integer');
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
baseUrl: required('BASE_URL', options.baseUrl ?? process.env.BASE_URL),
|
|
24
|
+
publicKey: required('SIGNING_PUBLIC_KEY', options.publicKey ?? process.env.SIGNING_PUBLIC_KEY),
|
|
25
|
+
privateKey: required('SIGNING_PRIVATE_KEY', options.privateKey ?? process.env.SIGNING_PRIVATE_KEY),
|
|
26
|
+
apiKey: options.apiKey ?? process.env.API_KEY,
|
|
27
|
+
// Only a node hosting paid functions settles payments.
|
|
28
|
+
operatorUrl: hostsPaidFunctions(resources)
|
|
29
|
+
? required('OPERATOR_URL', options.operatorUrl ?? process.env.OPERATOR_URL)
|
|
30
|
+
: options.operatorUrl ?? process.env.OPERATOR_URL,
|
|
31
|
+
costLimitLamports: costLimit,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
6
34
|
export class StellariumNode {
|
|
7
35
|
resources = [];
|
|
36
|
+
service;
|
|
8
37
|
async register(resource) {
|
|
9
38
|
this.resources.push(resource);
|
|
10
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Listens, then bootstraps from `startingPeer` if one is given.
|
|
42
|
+
* Rejects if the server can't listen or bootstrapping fails.
|
|
43
|
+
*/
|
|
11
44
|
async start(fastify, options) {
|
|
12
|
-
;
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
if (
|
|
19
|
-
fastify.log.
|
|
20
|
-
process.exit(1);
|
|
21
|
-
}
|
|
22
|
-
if (options.startingPeer) {
|
|
23
|
-
const startupService = new StartupService(fastify.log, service, options.startingPeer, process.env.BASE_URL, process.env.SIGNING_PUBLIC_KEY, process.env.SIGNING_PRIVATE_KEY);
|
|
24
|
-
Promise.resolve(startupService.seedFromPeer()).catch(err => {
|
|
25
|
-
fastify.log.error(err);
|
|
26
|
-
process.exit(1);
|
|
27
|
-
});
|
|
45
|
+
const config = resolveConfig(options, this.resources);
|
|
46
|
+
const db = await openDatabase(options.databasePath ?? DEFAULT_DATABASE_PATH);
|
|
47
|
+
fastify.addHook('onClose', async () => db.close());
|
|
48
|
+
const service = createService(db, this.resources, config, { approveGrant: options.approveGrant });
|
|
49
|
+
// A configured chain may be older than its stored renewal, or long expired; neither should stop the node.
|
|
50
|
+
for (const chain of options.grants ?? []) {
|
|
51
|
+
if (!await service.grantService.seed(chain)) {
|
|
52
|
+
fastify.log.warn(`Skipped expired grant ${chain[chain.length - 1]?.id} from the start options`);
|
|
28
53
|
}
|
|
29
|
-
|
|
54
|
+
}
|
|
55
|
+
this.service = service;
|
|
56
|
+
// Each run is scheduled after the previous one finishes, so slow renewals never overlap.
|
|
57
|
+
const refreshIntervalMs = options.grantRefreshIntervalMs ?? DEFAULT_GRANT_REFRESH_INTERVAL_MS;
|
|
58
|
+
let refresher;
|
|
59
|
+
let closed = false;
|
|
60
|
+
const scheduleRefresh = () => {
|
|
61
|
+
refresher = setTimeout(async () => {
|
|
62
|
+
try {
|
|
63
|
+
const { failed, removed } = await service.grantService.refreshDue();
|
|
64
|
+
for (const { id, error } of failed) {
|
|
65
|
+
fastify.log.warn(`Failed to renew grant ${id}: ${error}`);
|
|
66
|
+
}
|
|
67
|
+
for (const id of removed) {
|
|
68
|
+
fastify.log.info(`Removed long-expired grant ${id}: its issuer refused renewal`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
fastify.log.error(error, 'Failed to renew grants');
|
|
73
|
+
}
|
|
74
|
+
if (!closed) {
|
|
75
|
+
scheduleRefresh();
|
|
76
|
+
}
|
|
77
|
+
}, refreshIntervalMs);
|
|
78
|
+
refresher.unref();
|
|
79
|
+
};
|
|
80
|
+
scheduleRefresh();
|
|
81
|
+
fastify.addHook('onClose', async () => {
|
|
82
|
+
closed = true;
|
|
83
|
+
clearTimeout(refresher);
|
|
30
84
|
});
|
|
85
|
+
await fastify.register(routes, { prefix: '/v1', service, rateLimit: options.rateLimit });
|
|
86
|
+
const address = await fastify.listen({ port: options.port ?? 3000, host: options.host ?? '0.0.0.0' });
|
|
87
|
+
fastify.log.info(`server listening on ${address}`);
|
|
88
|
+
if (options.startingPeer) {
|
|
89
|
+
const startupService = new StartupService(fastify.log, service, options.startingPeer, config.baseUrl, config.publicKey, config.privateKey);
|
|
90
|
+
await startupService.seedFromPeer();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Issues a root grant for this node's resources. Hand the returned chain to the audience. */
|
|
94
|
+
async issueGrant(audience, capabilities, options) {
|
|
95
|
+
return this.started().grantService.issue(audience, capabilities, options);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Narrows a grant this node holds for another audience. Hand the returned chain to the audience.
|
|
99
|
+
* Name the held grant by its id, or by its issuer and id when two issuers used the same id.
|
|
100
|
+
*/
|
|
101
|
+
async delegateGrant(heldGrant, audience, capabilities, options) {
|
|
102
|
+
return this.started().grantService.delegate(heldGrant, audience, capabilities, options);
|
|
103
|
+
}
|
|
104
|
+
/** Asks another node for a grant on its resources, and stores it. Throws if refused. */
|
|
105
|
+
async requestGrant(providerBaseUrl, capabilities, details) {
|
|
106
|
+
return this.started().grantService.request(providerBaseUrl, capabilities, details);
|
|
107
|
+
}
|
|
108
|
+
/** Stores a grant chain addressed to this node. */
|
|
109
|
+
async addGrant(chain) {
|
|
110
|
+
return this.started().grantService.add(chain);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Refuses the grant, and every grant delegated from it, in calls to this node, and stops its renewal.
|
|
114
|
+
* Name a grant this node issued by its id, and any other grant by its issuer and id.
|
|
115
|
+
*/
|
|
116
|
+
async revokeGrant(grant) {
|
|
117
|
+
return this.started().grantService.revoke(grant);
|
|
118
|
+
}
|
|
119
|
+
/** Refuses this caller key in restricted calls, renewals, grant requests and paid calls. */
|
|
120
|
+
async blockCaller(publicKey, reason) {
|
|
121
|
+
return this.started().grantService.block(publicKey, reason);
|
|
122
|
+
}
|
|
123
|
+
/** False when the key wasn't blocked. */
|
|
124
|
+
async unblockCaller(publicKey) {
|
|
125
|
+
return this.started().grantService.unblock(publicKey);
|
|
126
|
+
}
|
|
127
|
+
started() {
|
|
128
|
+
if (!this.service) {
|
|
129
|
+
throw new Error('StellariumNode.start must be called first');
|
|
130
|
+
}
|
|
131
|
+
return this.service;
|
|
31
132
|
}
|
|
32
133
|
}
|
package/dist/service.d.ts
CHANGED
|
@@ -1,13 +1,36 @@
|
|
|
1
1
|
import { ResourceInvoker, ResourceResolver } from "requestscript";
|
|
2
|
+
import type { Database } from "sqlite";
|
|
2
3
|
import { PeerRepository } from "./peer/repository.js";
|
|
3
4
|
import { ResourceRepository, type StellariumResource } from "./resource/repository.js";
|
|
4
5
|
import { OperatorClient } from "./client/operatorClient.js";
|
|
6
|
+
import { type ApproveGrant, GrantService } from "./grant/service.js";
|
|
7
|
+
/** Settings a node runs with. `StellariumNode.start` fills these from its options, then the environment. */
|
|
8
|
+
export interface NodeConfig {
|
|
9
|
+
/** URL peers use to reach this node. Also its peer name. */
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
/** Ed25519 SPKI DER, base64url. */
|
|
12
|
+
publicKey: string;
|
|
13
|
+
/** Ed25519 PKCS#8 DER, base64url. */
|
|
14
|
+
privateKey: string;
|
|
15
|
+
/** Bearer token for `POST /v1/run`. When unset, `/v1/run` rejects every request. */
|
|
16
|
+
apiKey?: string;
|
|
17
|
+
/** Operator base URL, including `/v1`. Required when the node hosts paid functions. */
|
|
18
|
+
operatorUrl?: string;
|
|
19
|
+
/** Most this node will pay for one remote call. */
|
|
20
|
+
costLimitLamports: number;
|
|
21
|
+
}
|
|
5
22
|
export interface Service {
|
|
23
|
+
config: NodeConfig;
|
|
6
24
|
peerRepository: PeerRepository;
|
|
7
25
|
resourceRepository: ResourceRepository;
|
|
8
26
|
resourceResolver: ResourceResolver;
|
|
9
27
|
resourceInvoker: ResourceInvoker;
|
|
10
28
|
operatorClient: OperatorClient;
|
|
29
|
+
grantService: GrantService;
|
|
30
|
+
}
|
|
31
|
+
export interface ServiceOptions {
|
|
32
|
+
/** Decides `POST /v1/grants/request`. Without it, every request is refused. */
|
|
33
|
+
approveGrant?: ApproveGrant;
|
|
11
34
|
}
|
|
12
|
-
export declare function createService(resources: StellariumResource[]): Service;
|
|
35
|
+
export declare function createService(db: Database, resources: StellariumResource[], config: NodeConfig, options?: ServiceOptions): Service;
|
|
13
36
|
//# sourceMappingURL=service.d.ts.map
|
package/dist/service.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAClE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAEtD,OAAO,EAAE,kBAAkB,EAAE,KAAK,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAEvF,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAE5D,OAAO,EAAE,KAAK,YAAY,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAErE,4GAA4G;AAC5G,MAAM,WAAW,UAAU;IACvB,4DAA4D;IAC5D,OAAO,EAAE,MAAM,CAAC;IAChB,mCAAmC;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,oFAAoF;IACpF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uFAAuF;IACvF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mDAAmD;IACnD,iBAAiB,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,OAAO;IACpB,MAAM,EAAE,UAAU,CAAC;IACnB,cAAc,EAAE,cAAc,CAAC;IAC/B,kBAAkB,EAAE,kBAAkB,CAAC;IACvC,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,eAAe,EAAE,eAAe,CAAC;IACjC,cAAc,EAAE,cAAc,CAAC;IAC/B,YAAY,EAAE,YAAY,CAAC;CAC9B;AAED,MAAM,WAAW,cAAc;IAC3B,+EAA+E;IAC/E,YAAY,CAAC,EAAE,YAAY,CAAC;CAC/B;AAED,wBAAgB,aAAa,CAAC,EAAE,EAAE,QAAQ,EAAE,SAAS,EAAE,kBAAkB,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAoBtI"}
|
package/dist/service.js
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
|
-
import db from "./db/db.js";
|
|
2
1
|
import { PeerRepository } from "./peer/repository.js";
|
|
3
2
|
import { CombinationResourceResolver } from "./resource/resolver.js";
|
|
4
3
|
import { ResourceRepository } from "./resource/repository.js";
|
|
5
4
|
import { CombinationResourceInvoker } from "./resource/invoker.js";
|
|
6
5
|
import { OperatorClient } from "./client/operatorClient.js";
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
import { GrantRepository } from "./grant/repository.js";
|
|
7
|
+
import { GrantService } from "./grant/service.js";
|
|
8
|
+
export function createService(db, resources, config, options = {}) {
|
|
9
|
+
const resourceRepository = new ResourceRepository(db, resources, config.baseUrl);
|
|
9
10
|
const resourceResolver = new CombinationResourceResolver(resourceRepository);
|
|
10
|
-
|
|
11
|
-
const resourceInvoker = new CombinationResourceInvoker(resourceRepository,
|
|
12
|
-
const operatorClient = new OperatorClient(
|
|
11
|
+
const grantService = new GrantService(new GrantRepository(db), config, options.approveGrant);
|
|
12
|
+
const resourceInvoker = new CombinationResourceInvoker(resourceRepository, config.costLimitLamports, config.publicKey, config.privateKey, grantService);
|
|
13
|
+
const operatorClient = new OperatorClient(config.operatorUrl);
|
|
13
14
|
return {
|
|
15
|
+
config,
|
|
14
16
|
peerRepository: new PeerRepository(db),
|
|
15
17
|
resourceRepository,
|
|
16
18
|
resourceResolver,
|
|
17
19
|
resourceInvoker,
|
|
18
20
|
operatorClient,
|
|
21
|
+
grantService,
|
|
19
22
|
};
|
|
20
23
|
}
|
package/dist/startup.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { FastifyBaseLogger } from "fastify";
|
|
2
2
|
import { Service } from "./service.js";
|
|
3
|
+
/** Per request to a peer during startup. */
|
|
4
|
+
export declare const STARTUP_REQUEST_TIMEOUT_MS = 10000;
|
|
5
|
+
/** Peers and resources requested per page from the starting peer. It may return fewer. */
|
|
6
|
+
export declare const STARTUP_PAGE_SIZE = 100;
|
|
3
7
|
export declare class StartupService {
|
|
4
8
|
private readonly log;
|
|
5
9
|
private readonly service;
|
|
@@ -9,8 +13,18 @@ export declare class StartupService {
|
|
|
9
13
|
private readonly privateKey;
|
|
10
14
|
constructor(log: FastifyBaseLogger, service: Service, startingPeer: string, baseUrl: string, publicKey: string, privateKey: string);
|
|
11
15
|
seedFromPeer(): Promise<void>;
|
|
16
|
+
/** GET a JSON document from the starting peer, failing on a non-2xx response. */
|
|
17
|
+
private getFromStartingPeer;
|
|
18
|
+
/**
|
|
19
|
+
* Walks a paginated list on the starting peer, handing each page to `handlePage` before
|
|
20
|
+
* fetching the next, so the whole list is never held at once. Returns the item count.
|
|
21
|
+
*/
|
|
22
|
+
private forEachPageFromStartingPeer;
|
|
23
|
+
private isSelf;
|
|
12
24
|
populatePeers(): Promise<void>;
|
|
13
25
|
populateResources(): Promise<void>;
|
|
26
|
+
/** PUT to every peer, logging failures instead of throwing so one bad peer doesn't stop the rest. */
|
|
27
|
+
private putToPeers;
|
|
14
28
|
broadcastSelf(): Promise<void>;
|
|
15
29
|
}
|
|
16
30
|
//# sourceMappingURL=startup.d.ts.map
|
package/dist/startup.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"startup.d.ts","sourceRoot":"","sources":["../src/startup.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAI5C,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAEvC,qBAAa,cAAc;IAEnB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,YAAY;IAC7B,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,UAAU;IAN/B,YACqB,GAAG,EAAE,iBAAiB,EACtB,OAAO,EAAE,OAAO,EAChB,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EACnC;IAGE,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAUlC;
|
|
1
|
+
{"version":3,"file":"startup.d.ts","sourceRoot":"","sources":["../src/startup.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAI5C,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAEvC,4CAA4C;AAC5C,eAAO,MAAM,0BAA0B,QAAS,CAAC;AAEjD,0FAA0F;AAC1F,eAAO,MAAM,iBAAiB,MAAM,CAAC;AAKrC,qBAAa,cAAc;IAEnB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,YAAY;IAC7B,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,UAAU;IAN/B,YACqB,GAAG,EAAE,iBAAiB,EACtB,OAAO,EAAE,OAAO,EAChB,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EACnC;IAGE,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAUlC;IAED,iFAAiF;YACnE,mBAAmB;IAQjC;;;OAGG;YACW,2BAA2B;IAsBzC,OAAO,CAAC,MAAM;IAIR,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CAkBnC;IAEK,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC,CAWvC;IAED,qGAAqG;YACvF,UAAU;IAqBlB,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CA8BnC;CACJ"}
|
package/dist/startup.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import { signData } from "./crypto/utils.js";
|
|
2
|
+
import { peerAnnouncementPayload } from "./peer/repository.js";
|
|
3
|
+
import { resourceAnnouncementPayload } from "./resource/repository.js";
|
|
4
|
+
/** Per request to a peer during startup. */
|
|
5
|
+
export const STARTUP_REQUEST_TIMEOUT_MS = 10_000;
|
|
6
|
+
/** Peers and resources requested per page from the starting peer. It may return fewer. */
|
|
7
|
+
export const STARTUP_PAGE_SIZE = 100;
|
|
8
|
+
// 10 minutes
|
|
9
|
+
const SIGNATURE_LIFETIME_MS = 1000 * 60 * 10;
|
|
2
10
|
export class StartupService {
|
|
3
11
|
log;
|
|
4
12
|
service;
|
|
@@ -14,7 +22,7 @@ export class StartupService {
|
|
|
14
22
|
this.publicKey = publicKey;
|
|
15
23
|
this.privateKey = privateKey;
|
|
16
24
|
}
|
|
17
|
-
//
|
|
25
|
+
// Learn peers and resources from the starting peer, then announce this node to every peer.
|
|
18
26
|
async seedFromPeer() {
|
|
19
27
|
this.log.info(`Seeding from peer ${this.startingPeer}`);
|
|
20
28
|
await this.populatePeers();
|
|
@@ -23,101 +31,107 @@ export class StartupService {
|
|
|
23
31
|
await this.broadcastSelf();
|
|
24
32
|
this.log.info(`Finished seeding from peer ${this.startingPeer}`);
|
|
25
33
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
30
|
-
|
|
34
|
+
/** GET a JSON document from the starting peer, failing on a non-2xx response. */
|
|
35
|
+
async getFromStartingPeer(path) {
|
|
36
|
+
const response = await fetch(`${this.startingPeer}/v1${path}`, { signal: AbortSignal.timeout(STARTUP_REQUEST_TIMEOUT_MS) });
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
throw new Error(`Starting peer ${this.startingPeer} responded ${response.status} to GET /v1${path}`);
|
|
31
39
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
return await response.json();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Walks a paginated list on the starting peer, handing each page to `handlePage` before
|
|
44
|
+
* fetching the next, so the whole list is never held at once. Returns the item count.
|
|
45
|
+
*/
|
|
46
|
+
async forEachPageFromStartingPeer(path, key, handlePage) {
|
|
47
|
+
let offset = 0;
|
|
48
|
+
let count = 0;
|
|
49
|
+
while (true) {
|
|
50
|
+
const data = await this.getFromStartingPeer(`${path}?offset=${offset}&limit=${STARTUP_PAGE_SIZE}`);
|
|
51
|
+
const items = data[key];
|
|
52
|
+
const nextOffset = data.nextOffset ?? null;
|
|
53
|
+
// The next offset must move forward, or a bad peer could keep startup looping forever.
|
|
54
|
+
if (!Array.isArray(items) || (nextOffset !== null && (!Number.isInteger(nextOffset) || nextOffset <= offset))) {
|
|
55
|
+
throw new Error('Invalid response from peer');
|
|
56
|
+
}
|
|
57
|
+
await handlePage(items);
|
|
58
|
+
count += items.length;
|
|
59
|
+
if (nextOffset === null) {
|
|
60
|
+
return count;
|
|
61
|
+
}
|
|
62
|
+
offset = nextOffset;
|
|
36
63
|
}
|
|
37
|
-
|
|
38
|
-
|
|
64
|
+
}
|
|
65
|
+
isSelf(peer) {
|
|
66
|
+
return peer.publicKey === this.publicKey || peer.baseUrl === this.baseUrl;
|
|
67
|
+
}
|
|
68
|
+
async populatePeers() {
|
|
69
|
+
this.log.info(`Populating peers from ${this.startingPeer}`);
|
|
70
|
+
// Known peers are skipped, so this is safe to repeat on every restart, and a run cut off
|
|
71
|
+
// partway through the pages is completed on the next one.
|
|
72
|
+
let count = 0;
|
|
73
|
+
const savePeers = async (peers) => {
|
|
74
|
+
for (const peer of peers.filter(peer => !this.isSelf(peer))) {
|
|
39
75
|
await this.service.peerRepository.create(peer);
|
|
76
|
+
count++;
|
|
40
77
|
}
|
|
41
|
-
}
|
|
78
|
+
};
|
|
42
79
|
// Save the starting peer to the database.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
baseUrl: startingPeerData.baseUrl,
|
|
47
|
-
publicKey: startingPeerData.publicKey,
|
|
48
|
-
name: startingPeerData.name,
|
|
49
|
-
});
|
|
50
|
-
this.log.info(`Populated ${data.peers.length + 1} peers`);
|
|
80
|
+
await savePeers([await this.getFromStartingPeer('/self')]);
|
|
81
|
+
await this.forEachPageFromStartingPeer('/peers', 'peers', savePeers);
|
|
82
|
+
this.log.info(`Populated ${count} peers`);
|
|
51
83
|
}
|
|
52
84
|
async populateResources() {
|
|
53
85
|
this.log.info(`Populating resources from ${this.startingPeer}`);
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
86
|
+
// Known resources are skipped, so this is safe to repeat on every restart.
|
|
87
|
+
const count = await this.forEachPageFromStartingPeer('/resources', 'resources', async (resources) => {
|
|
88
|
+
for (const resource of resources) {
|
|
89
|
+
await this.service.resourceRepository.learn(resource);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
this.log.info(`Learned up to ${count} resources`);
|
|
93
|
+
}
|
|
94
|
+
/** PUT to every peer, logging failures instead of throwing so one bad peer doesn't stop the rest. */
|
|
95
|
+
async putToPeers(peers, path, body, description) {
|
|
96
|
+
for (const peer of peers) {
|
|
97
|
+
try {
|
|
98
|
+
const response = await fetch(`${peer.baseUrl}/v1${path}`, {
|
|
99
|
+
method: 'PUT',
|
|
100
|
+
headers: {
|
|
101
|
+
'Content-Type': 'application/json',
|
|
102
|
+
},
|
|
103
|
+
body: JSON.stringify(body),
|
|
104
|
+
signal: AbortSignal.timeout(STARTUP_REQUEST_TIMEOUT_MS),
|
|
105
|
+
});
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
this.log.warn(`Failed to broadcast ${description} to node ${peer.baseUrl}: ${response.status} ${await response.text()}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
this.log.warn(error, `Failed to broadcast ${description} to node ${peer.baseUrl}`);
|
|
62
112
|
}
|
|
63
113
|
}
|
|
64
|
-
this.log.info(`Populated ${data.resources.length} resources`);
|
|
65
114
|
}
|
|
66
115
|
async broadcastSelf() {
|
|
67
116
|
this.log.info(`Broadcasting self to peers`);
|
|
68
117
|
// Let the peers know about this node.
|
|
69
|
-
const peers = await this.service.peerRepository.getPeerList();
|
|
70
|
-
|
|
71
|
-
const peerSignatureExpiry = new Date(Date.now() +
|
|
72
|
-
const peerSignature = signData(
|
|
73
|
-
|
|
74
|
-
name: this.baseUrl,
|
|
75
|
-
expiry: peerSignatureExpiry,
|
|
76
|
-
}, this.privateKey);
|
|
77
|
-
for (const peer of peers) {
|
|
78
|
-
const response = await fetch(`${peer.baseUrl}/v1/peers`, {
|
|
79
|
-
method: 'PUT',
|
|
80
|
-
headers: {
|
|
81
|
-
'Content-Type': 'application/json',
|
|
82
|
-
},
|
|
83
|
-
body: JSON.stringify({
|
|
84
|
-
baseUrl: this.baseUrl,
|
|
85
|
-
name: this.baseUrl,
|
|
86
|
-
publicKey: this.publicKey,
|
|
87
|
-
signature: peerSignature,
|
|
88
|
-
expiry: peerSignatureExpiry,
|
|
89
|
-
}),
|
|
90
|
-
});
|
|
91
|
-
if (!response.ok) {
|
|
92
|
-
this.log.warn(`Failed to broadcast self to node ${peer.baseUrl}: ${response.statusText}`);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
118
|
+
const peers = (await this.service.peerRepository.getPeerList()).filter(peer => !this.isSelf(peer));
|
|
119
|
+
const self = { baseUrl: this.baseUrl, name: this.baseUrl, publicKey: this.publicKey };
|
|
120
|
+
const peerSignatureExpiry = new Date(Date.now() + SIGNATURE_LIFETIME_MS).toISOString();
|
|
121
|
+
const peerSignature = signData(peerAnnouncementPayload(self, peerSignatureExpiry), this.privateKey);
|
|
122
|
+
await this.putToPeers(peers, '/peers', { ...self, signature: peerSignature, expiry: peerSignatureExpiry }, 'self');
|
|
95
123
|
this.log.info(`Broadcasted self to ${peers.length} peers`);
|
|
96
124
|
this.log.info(`Broadcasting resources to peers`);
|
|
97
125
|
// Let the peers know about the resources available on this node.
|
|
98
126
|
const resources = await this.service.resourceRepository.getInternalResources();
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
127
|
+
const resourceSignatureExpiry = new Date(Date.now() + SIGNATURE_LIFETIME_MS).toISOString();
|
|
128
|
+
const resourceSignature = signData(resourceAnnouncementPayload(resources, resourceSignatureExpiry), this.privateKey);
|
|
129
|
+
await this.putToPeers(peers, '/all-resources', {
|
|
130
|
+
publicKey: this.publicKey,
|
|
102
131
|
resources,
|
|
103
132
|
expiry: resourceSignatureExpiry,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const response = await fetch(`${peer.baseUrl}/v1/all-resources`, {
|
|
107
|
-
method: 'PUT',
|
|
108
|
-
headers: {
|
|
109
|
-
'Content-Type': 'application/json',
|
|
110
|
-
},
|
|
111
|
-
body: JSON.stringify({
|
|
112
|
-
resources,
|
|
113
|
-
expiry: resourceSignatureExpiry,
|
|
114
|
-
signature: resourceSignature,
|
|
115
|
-
}),
|
|
116
|
-
});
|
|
117
|
-
if (!response.ok) {
|
|
118
|
-
this.log.warn(`Failed to broadcast resources to node ${peer.baseUrl}: ${response.statusText}`);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
133
|
+
signature: resourceSignature,
|
|
134
|
+
}, 'resources');
|
|
121
135
|
this.log.info(`Broadcasted ${resources.length} resources to ${peers.length} peers`);
|
|
122
136
|
}
|
|
123
137
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"operatorClient.test.d.ts","sourceRoot":"","sources":["../../../src/tests/client/operatorClient.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { OperatorClient, SETTLE_PAYMENT_MAX_RETRIES } from "../../client/operatorClient.js";
|
|
3
|
+
describe("settle payment retries", () => {
|
|
4
|
+
const payment = {
|
|
5
|
+
voucher: {
|
|
6
|
+
requestId: "request-1",
|
|
7
|
+
invocationId: "invocation-1",
|
|
8
|
+
amount: 10000,
|
|
9
|
+
recipientAddress: "4HkmXgXCJ8tw1ih5VFHetRt4kWdo1VLBVmV7B5p6ctK2",
|
|
10
|
+
signature: "signature",
|
|
11
|
+
expiresAt: "2030-01-01T00:00:00.000Z",
|
|
12
|
+
invocationHash: "hash",
|
|
13
|
+
publicKey: "public-key",
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
const ok = () => new Response(JSON.stringify({ success: true }), { status: 200 });
|
|
17
|
+
const status = (code, headers) => new Response(null, { status: code, headers });
|
|
18
|
+
const networkError = (code) => new TypeError("fetch failed", { cause: Object.assign(new Error(code), { code }) });
|
|
19
|
+
let fetchMock;
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.useFakeTimers();
|
|
22
|
+
vi.spyOn(console, "error").mockImplementation(() => { });
|
|
23
|
+
fetchMock = vi.fn();
|
|
24
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.useRealTimers();
|
|
28
|
+
vi.unstubAllGlobals();
|
|
29
|
+
vi.restoreAllMocks();
|
|
30
|
+
});
|
|
31
|
+
test("retries 429 responses with exponential backoff, then succeeds", async () => {
|
|
32
|
+
fetchMock
|
|
33
|
+
.mockResolvedValueOnce(status(429))
|
|
34
|
+
.mockResolvedValueOnce(status(429))
|
|
35
|
+
.mockResolvedValueOnce(ok());
|
|
36
|
+
const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
|
|
37
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
38
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
39
|
+
await vi.advanceTimersByTimeAsync(999);
|
|
40
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
41
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
42
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
43
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
44
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
45
|
+
await pending;
|
|
46
|
+
expect(console.error).not.toHaveBeenCalled();
|
|
47
|
+
const bodies = fetchMock.mock.calls.map(([, init]) => init.body);
|
|
48
|
+
expect(new Set(bodies).size).toBe(1);
|
|
49
|
+
});
|
|
50
|
+
test("waits for Retry-After when it is longer than the backoff", async () => {
|
|
51
|
+
fetchMock.mockResolvedValueOnce(status(429, { "retry-after": "30" })).mockResolvedValueOnce(ok());
|
|
52
|
+
const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
|
|
53
|
+
await vi.advanceTimersByTimeAsync(29_999);
|
|
54
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
55
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
56
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
57
|
+
await pending;
|
|
58
|
+
});
|
|
59
|
+
test.each([408, 500, 502, 503, 504])("retries %i responses", async (code) => {
|
|
60
|
+
fetchMock.mockResolvedValueOnce(status(code)).mockResolvedValueOnce(ok());
|
|
61
|
+
const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
|
|
62
|
+
await vi.runAllTimersAsync();
|
|
63
|
+
await pending;
|
|
64
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
65
|
+
expect(console.error).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
test.each([400, 401, 404, 409, 422])("does not retry %i responses", async (code) => {
|
|
68
|
+
fetchMock.mockResolvedValue(status(code));
|
|
69
|
+
await new OperatorClient("https://operator.example/v1").settlePayment(payment);
|
|
70
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(console.error).toHaveBeenCalledOnce();
|
|
72
|
+
});
|
|
73
|
+
test.each(["ECONNREFUSED", "ECONNRESET"])("retries %s network errors", async (code) => {
|
|
74
|
+
fetchMock.mockRejectedValueOnce(networkError(code)).mockResolvedValueOnce(ok());
|
|
75
|
+
const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
|
|
76
|
+
await vi.runAllTimersAsync();
|
|
77
|
+
await pending;
|
|
78
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
79
|
+
});
|
|
80
|
+
test("rethrows the last network error once retries are exhausted", async () => {
|
|
81
|
+
fetchMock.mockRejectedValue(networkError("ECONNREFUSED"));
|
|
82
|
+
const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
|
|
83
|
+
const assertion = expect(pending).rejects.toThrow("fetch failed");
|
|
84
|
+
await vi.runAllTimersAsync();
|
|
85
|
+
await assertion;
|
|
86
|
+
expect(fetchMock).toHaveBeenCalledTimes(1 + SETTLE_PAYMENT_MAX_RETRIES);
|
|
87
|
+
});
|
|
88
|
+
test("gives up after the maximum number of retries", async () => {
|
|
89
|
+
fetchMock.mockResolvedValue(status(429));
|
|
90
|
+
const pending = new OperatorClient("https://operator.example/v1").settlePayment(payment);
|
|
91
|
+
await vi.runAllTimersAsync();
|
|
92
|
+
await pending;
|
|
93
|
+
expect(fetchMock).toHaveBeenCalledTimes(1 + SETTLE_PAYMENT_MAX_RETRIES);
|
|
94
|
+
expect(console.error).toHaveBeenCalledOnce();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"db.test.d.ts","sourceRoot":"","sources":["../../../src/tests/db/db.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { open } from "sqlite";
|
|
5
|
+
import sqlite3 from "sqlite3";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
7
|
+
import { openDatabase } from "../../db/db.js";
|
|
8
|
+
describe("openDatabase", () => {
|
|
9
|
+
let dir;
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
dir = await mkdtemp(path.join(tmpdir(), "stellarium-db-"));
|
|
12
|
+
});
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await rm(dir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
test("can be reopened without errors", async () => {
|
|
17
|
+
const filename = path.join(dir, "node.db");
|
|
18
|
+
await (await openDatabase(filename)).close();
|
|
19
|
+
const db = await openDatabase(filename);
|
|
20
|
+
expect(await db.get("PRAGMA user_version")).toEqual({ user_version: 3 });
|
|
21
|
+
await db.close();
|
|
22
|
+
});
|
|
23
|
+
test("rebuilds resource tables from the old schema and keeps peers", async () => {
|
|
24
|
+
const filename = path.join(dir, "old.db");
|
|
25
|
+
const old = await open({ filename, driver: sqlite3.Database });
|
|
26
|
+
await old.exec(`
|
|
27
|
+
CREATE TABLE peer (id INTEGER PRIMARY KEY AUTOINCREMENT, base_url TEXT NOT NULL, public_key TEXT NOT NULL, name TEXT NOT NULL,
|
|
28
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE (public_key), UNIQUE (base_url), UNIQUE (name));
|
|
29
|
+
CREATE TABLE resource (id INTEGER PRIMARY KEY AUTOINCREMENT, peer_id INTEGER, path TEXT NOT NULL, name TEXT NOT NULL,
|
|
30
|
+
base_url TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE (path, name), UNIQUE (base_url));
|
|
31
|
+
INSERT INTO peer (base_url, public_key, name, created_at, updated_at) VALUES ('http://p', 'key', 'p', 'now', 'now');
|
|
32
|
+
INSERT INTO resource (path, name, base_url, created_at, updated_at) VALUES ('com.p', 'A', 'http://p', 'now', 'now');
|
|
33
|
+
`);
|
|
34
|
+
await old.close();
|
|
35
|
+
const db = await openDatabase(filename);
|
|
36
|
+
expect(await db.all("SELECT public_key FROM peer")).toEqual([{ public_key: "key" }]);
|
|
37
|
+
expect(await db.all("SELECT * FROM resource")).toEqual([]);
|
|
38
|
+
// Two resources on one base URL failed under the old UNIQUE (base_url).
|
|
39
|
+
await db.exec(`
|
|
40
|
+
INSERT INTO resource (path, name, base_url, created_at, updated_at) VALUES ('com.p', 'A', 'http://p', 'now', 'now');
|
|
41
|
+
INSERT INTO resource (path, name, base_url, created_at, updated_at) VALUES ('com.p', 'B', 'http://p', 'now', 'now');
|
|
42
|
+
`);
|
|
43
|
+
await db.close();
|
|
44
|
+
});
|
|
45
|
+
test("adds the restricted column to a version 1 database and keeps held grants on later opens", async () => {
|
|
46
|
+
const filename = path.join(dir, "v1.db");
|
|
47
|
+
const old = await open({ filename, driver: sqlite3.Database });
|
|
48
|
+
await old.exec(`
|
|
49
|
+
CREATE TABLE resource_function (id INTEGER PRIMARY KEY AUTOINCREMENT, resource_id INTEGER NOT NULL, name TEXT NOT NULL,
|
|
50
|
+
return_type TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE (resource_id, name));
|
|
51
|
+
PRAGMA user_version = 1;
|
|
52
|
+
`);
|
|
53
|
+
await old.close();
|
|
54
|
+
const db = await openDatabase(filename);
|
|
55
|
+
const columns = await db.all("PRAGMA table_info(resource_function)");
|
|
56
|
+
expect(columns.map(column => column.name)).toContain("restricted");
|
|
57
|
+
await db.run("INSERT INTO held_grant (issuer, id, root_issuer_base_url, chain, issued_at, expires_at, updated_at) VALUES ('k', 'g', 'http://p', '[]', 'now', 'now', 'now')");
|
|
58
|
+
await db.close();
|
|
59
|
+
// Grants are not a cache of anything a peer resends, so a reopen must not drop them.
|
|
60
|
+
const reopened = await openDatabase(filename);
|
|
61
|
+
expect(await reopened.all("SELECT id FROM held_grant")).toEqual([{ id: "g" }]);
|
|
62
|
+
await reopened.close();
|
|
63
|
+
});
|
|
64
|
+
});
|