@jskit-ai/connectors-core 0.2.14 → 0.2.15
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/CHANGELOG.md +10 -0
- package/README.md +11 -2
- package/package.json +14 -3
- package/src/server/ConnectionStoreFeature.js +34 -0
- package/src/server/ConnectorsFeature.js +13 -3
- package/src/server/connectionService.js +7 -1
- package/src/server/connectionStore.js +86 -0
- package/src/server/connectorResources.js +48 -0
- package/src/server/knexConnectionStore.js +30 -49
- package/src/server/storage.js +1 -0
- package/test/connectionService.test.js +16 -0
- package/test/connectionStoreFeature.test.js +53 -0
- package/test/knexConnectionStore.mysql.test.js +199 -0
- package/test/withoutDatabase.test.js +53 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- `@jskit-ai/database-runtime` and `@jskit-ai/resource-runtime` are optional peer dependencies, as they are of assistant-runtime, instead of dependencies: an app without a database installs, composes and starts the package without them, where installing them made boot refuse it for want of a database driver. An app that uses its knex connection store (`./server/storage`) has them through its database.
|
|
6
|
+
- `createKnexConnectionStore` owns the connector tables through resource-runtime's `createStandaloneResources`, with the connection store's declared system scope, instead of building a resource runtime and host of its own.
|
|
7
|
+
- `connectors.core` without a store requires `connectors.store` again, with a hint, so the kernel's startup error names the missing capability and says to register `createConnectionStoreFeature({ protection })` or pass `createConnectorsFeature({ store })`, instead of a check in its own setup.
|
|
8
|
+
- The connection store locks or creates each connection row with its handle's `lockOrCreate`, in place of a helper of its own with the same behaviour.
|
|
9
|
+
- `connectors.core` without a `store` and without `createConnectionStoreFeature({ protection })` fails at startup naming that feature and where to register it, where the runtime only said a capability was missing; `createConnectionService` given no store names the same fix.
|
|
10
|
+
- `withConnection` creates a missing connection row inside the work's own transaction (an insert that skips an existing id), so work that fails leaves no row behind. Two first uses of one connection that collide on that insert before either work runs (a deadlock on MySQL's and MariaDB's gap locks, a duplicate key elsewhere) run the loser again, up to three times; work that has started never runs again. An existing row costs one locking read, where the store read it once outside the transaction and again under the lock.
|
|
11
|
+
- `pruneExpiredAttempts` counts the deleted attempts in the database instead of reading their ids.
|
|
12
|
+
|
|
3
13
|
## 0.2.0 - 2026-10-29
|
|
4
14
|
|
|
5
15
|
- Translation ready: every user-visible text is a message key with its English in `locales/en.json`, so an app translates it by supplying translation files only. With no translations, texts and behaviour are unchanged. See the guide "Translating a JSKIT app".
|
package/README.md
CHANGED
|
@@ -442,8 +442,17 @@ an old key before those rows are rewritten makes them unreadable. Applications
|
|
|
442
442
|
with an existing vault can instead provide compatible `seal(value, binding)`
|
|
443
443
|
and `open(ciphertext, binding)` methods.
|
|
444
444
|
|
|
445
|
-
The Knex store uses the application's own Knex client
|
|
446
|
-
|
|
445
|
+
The Knex store uses the application's own Knex client; editing portable
|
|
446
|
+
configuration does not install or activate a database provider. It owns the
|
|
447
|
+
connector tables as table resources and reads and writes them through their
|
|
448
|
+
handles. Code outside a JSKIT server, such as a command-line script, uses
|
|
449
|
+
`createKnexConnectionStore`. A JSKIT application instead adds
|
|
450
|
+
`createConnectionStoreFeature({ protection })`, also from
|
|
451
|
+
`@jskit-ai/connectors-core/server/storage`: it owns the tables through the
|
|
452
|
+
application's `runtime.resources` and provides the store as `connectors.store`,
|
|
453
|
+
which `createConnectorsFeature(options)` uses when `options.store` is absent. An
|
|
454
|
+
application that passes its own `store` (the file store, for example) needs no
|
|
455
|
+
database.
|
|
447
456
|
|
|
448
457
|
The package declares its authoritative migration directory in `package.json`.
|
|
449
458
|
Use the normal app-owned `knexfile.js` and migration scripts described by the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/connectors-core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.15",
|
|
4
4
|
"description": "Portable integration configuration and server-side account connection runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"./locales/en.json": "./locales/en.json"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
+
"@jskit-ai/resource-core": "0.1.205",
|
|
18
19
|
"jose": "^6.1.3",
|
|
19
20
|
"json-rest-schema": "^1.0.17",
|
|
20
21
|
"oauth4webapi": "3.8.8",
|
|
@@ -55,7 +56,17 @@
|
|
|
55
56
|
}
|
|
56
57
|
},
|
|
57
58
|
"peerDependencies": {
|
|
58
|
-
"@jskit-ai/
|
|
59
|
-
"@jskit-ai/
|
|
59
|
+
"@jskit-ai/database-runtime": "0.1.263",
|
|
60
|
+
"@jskit-ai/http-runtime": "0.2.14",
|
|
61
|
+
"@jskit-ai/kernel": "0.2.14",
|
|
62
|
+
"@jskit-ai/resource-runtime": "0.2.12"
|
|
63
|
+
},
|
|
64
|
+
"peerDependenciesMeta": {
|
|
65
|
+
"@jskit-ai/database-runtime": {
|
|
66
|
+
"optional": true
|
|
67
|
+
},
|
|
68
|
+
"@jskit-ai/resource-runtime": {
|
|
69
|
+
"optional": true
|
|
70
|
+
}
|
|
60
71
|
}
|
|
61
72
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { defineFeature } from "@jskit-ai/kernel/server/features";
|
|
2
|
+
import { createConnectionStore } from "./connectionStore.js";
|
|
3
|
+
import { CONNECTOR_TABLES } from "./connectorResources.js";
|
|
4
|
+
|
|
5
|
+
const CONNECTION_STORE_FEATURE = "connectors.storage";
|
|
6
|
+
const CONNECTION_STORE_SYSTEM_SCOPE = "connector rows are keyed by their owner's application and subject and their integration, which is their scope, not by a workspace or user";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The owner of the connector tables in a JSKIT application: it provides the connection store on their handles as
|
|
10
|
+
* `connectors.store`, which createConnectorsFeature() uses when it is given no store. An application that keeps
|
|
11
|
+
* connections in files passes createFileConnectionStore() instead and leaves this feature out.
|
|
12
|
+
* @param {{ protection: import("./types.js").CredentialProtection }} options
|
|
13
|
+
*/
|
|
14
|
+
function createConnectionStoreFeature({ protection }) {
|
|
15
|
+
return defineFeature({
|
|
16
|
+
id: CONNECTION_STORE_FEATURE,
|
|
17
|
+
domain: "connectors",
|
|
18
|
+
owns: CONNECTOR_TABLES,
|
|
19
|
+
systemScope: CONNECTION_STORE_SYSTEM_SCOPE,
|
|
20
|
+
provides: {
|
|
21
|
+
store: "connectors.store"
|
|
22
|
+
},
|
|
23
|
+
/**
|
|
24
|
+
* @param {object} dependencies
|
|
25
|
+
* @param {Record<string, import("@jskit-ai/resource-runtime/server/types").ResourceHandle>} dependencies.resources
|
|
26
|
+
* @param {(reason: string) => import("@jskit-ai/resource-runtime/server/types").SystemContext} dependencies.systemContext
|
|
27
|
+
*/
|
|
28
|
+
setup({ resources, systemContext }) {
|
|
29
|
+
return { store: createConnectionStore({ resources, systemContext, protection }) };
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { CONNECTION_STORE_FEATURE, CONNECTION_STORE_SYSTEM_SCOPE, createConnectionStoreFeature };
|
|
@@ -65,14 +65,24 @@ function createConnectorsActions({ connections }) {
|
|
|
65
65
|
]);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* The `connectors.core` feature. Given no `store`, it keeps connections in the `connectors.store` capability, which
|
|
70
|
+
* createConnectionStoreFeature() provides on the connector tables; without that feature the app fails at startup, naming it.
|
|
71
|
+
* @param {Omit<import("./types.js").ConnectionServiceOptions, "store"> & { store?: import("./types.js").ConnectionStore }} options
|
|
72
|
+
*/
|
|
69
73
|
function createConnectorsFeature(options) {
|
|
70
74
|
return defineFeature({
|
|
71
75
|
id: "connectors.core",
|
|
72
76
|
domain: "connectors",
|
|
77
|
+
requires: options.store ? {} : { store: "connectors.store" },
|
|
78
|
+
hints: options.store ? {} : {
|
|
79
|
+
"connectors.store": "Register createConnectionStoreFeature({ protection }) from @jskit-ai/connectors-core/server/storage " +
|
|
80
|
+
"in your server providers, or pass createConnectorsFeature({ store })."
|
|
81
|
+
},
|
|
73
82
|
provides: { connections: "connectors.core" },
|
|
74
|
-
|
|
75
|
-
|
|
83
|
+
/** @param {{ store?: import("./types.js").ConnectionStore }} dependencies */
|
|
84
|
+
setup({ store = options.store }) {
|
|
85
|
+
return { connections: createConnectionService({ ...options, store }) };
|
|
76
86
|
},
|
|
77
87
|
actions({ connections }) {
|
|
78
88
|
return createConnectorsActions({ connections });
|
|
@@ -82,7 +82,13 @@ function createConnectionService({
|
|
|
82
82
|
throw new TypeError("Connections require application authorization and reference resolution.");
|
|
83
83
|
}
|
|
84
84
|
// readable-check: allow method-probe - input validation: the application passes its own connection store
|
|
85
|
-
if (typeof store?.withConnection !== "function")
|
|
85
|
+
if (typeof store?.withConnection !== "function") {
|
|
86
|
+
throw new TypeError(
|
|
87
|
+
"Connections require a connection store with withConnection(): register createConnectionStoreFeature({ protection }) from " +
|
|
88
|
+
"@jskit-ai/connectors-core/server/storage in your server providers and pass its connectors.store, or pass " +
|
|
89
|
+
"createFileConnectionStore() or createKnexConnectionStore() outside a JSKIT server."
|
|
90
|
+
);
|
|
91
|
+
}
|
|
86
92
|
const connectionCredentials = createConnectionCredentials({ config, resolveReference });
|
|
87
93
|
const providerRequests = createProviderRequests({ fetchImpl, now, resolveReference });
|
|
88
94
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The application's credential protection, checked before it is used.
|
|
7
|
+
* @param {import("./types.js").CredentialProtection} protection
|
|
8
|
+
*/
|
|
9
|
+
function requireProtection(protection) {
|
|
10
|
+
// readable-check: allow method-probe - input validation: the application passes its credential protection
|
|
11
|
+
if (typeof protection?.seal !== "function" || typeof protection?.open !== "function") {
|
|
12
|
+
throw new TypeError("Connector storage requires credential protection.");
|
|
13
|
+
}
|
|
14
|
+
return protection;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Connections kept encrypted in the connector tables, on their table resources' handles, each locked in a transaction
|
|
19
|
+
* while it is used. Every call works in a system context, because a row's scope is its key.
|
|
20
|
+
* @param {object} dependencies
|
|
21
|
+
* @param {Record<string, import("@jskit-ai/resource-runtime/server/types").ResourceHandle>} dependencies.resources The handles of CONNECTOR_TABLES.
|
|
22
|
+
* @param {(reason: string) => import("@jskit-ai/resource-runtime/server/types").SystemContext} dependencies.systemContext
|
|
23
|
+
* @param {import("./types.js").CredentialProtection} dependencies.protection
|
|
24
|
+
*/
|
|
25
|
+
function createConnectionStore({ resources, systemContext, protection }) {
|
|
26
|
+
requireProtection(protection);
|
|
27
|
+
const { connectorConnections: connections, connectorAuthorizationAttempts: attempts } = resources;
|
|
28
|
+
const connectionOptions = Object.freeze({ context: systemContext("a connection and its attempts are read and changed by their owner and integration key") });
|
|
29
|
+
const pruneOptions = Object.freeze({ context: systemContext("maintenance prunes every connection's expired authorization attempts") });
|
|
30
|
+
|
|
31
|
+
async function withConnection({ owner, integrationId }, work) {
|
|
32
|
+
const connectionKey = hash(JSON.stringify([owner.applicationId, owner.subjectId, integrationId]));
|
|
33
|
+
const connectionBinding = `connection:${connectionKey}`;
|
|
34
|
+
// Retain this row on disconnect so other processes continue to lock the same identity.
|
|
35
|
+
return connections.lockOrCreate({ id: connectionKey, payload: null }, connectionOptions, async (row, txOptions) => {
|
|
36
|
+
const writeOptions = { ...txOptions, returning: "none" };
|
|
37
|
+
return work({
|
|
38
|
+
connection: row.payload ? await protection.open(row.payload, connectionBinding) : null,
|
|
39
|
+
save: async (connection) => {
|
|
40
|
+
const payload = await protection.seal(connection, connectionBinding);
|
|
41
|
+
await connections.patch(connectionKey, { payload }, writeOptions);
|
|
42
|
+
},
|
|
43
|
+
remove: async () => {
|
|
44
|
+
await connections.patch(connectionKey, { payload: null }, writeOptions);
|
|
45
|
+
await attempts.deleteWhere({ connectionKey }, txOptions);
|
|
46
|
+
},
|
|
47
|
+
putAttempt: async (attempt) => {
|
|
48
|
+
const attemptKey = hash(attempt.state);
|
|
49
|
+
await attempts.create({
|
|
50
|
+
id: attemptKey,
|
|
51
|
+
connectionKey,
|
|
52
|
+
expiresAt: attempt.expiresAt,
|
|
53
|
+
payload: await protection.seal(attempt, `attempt:${connectionKey}:${attemptKey}`)
|
|
54
|
+
}, writeOptions);
|
|
55
|
+
},
|
|
56
|
+
latestAttempt: async ({ after }) => {
|
|
57
|
+
const { records: [attempt] } = await attempts.query({
|
|
58
|
+
filters: { connectionKey, expiresAfter: after },
|
|
59
|
+
sort: "-expiresAt",
|
|
60
|
+
page: { size: 1 }
|
|
61
|
+
}, txOptions);
|
|
62
|
+
return attempt ? protection.open(attempt.payload, `attempt:${connectionKey}:${attempt.id}`) : null;
|
|
63
|
+
},
|
|
64
|
+
consumeAttempt: async (state) => {
|
|
65
|
+
const attemptKey = hash(state);
|
|
66
|
+
// A locking read sees an attempt another transaction consumed while this one waited for the connection.
|
|
67
|
+
const [attempt] = await attempts.lockMany([attemptKey], { ...txOptions, missing: "skip" });
|
|
68
|
+
if (attempt?.connectionKey !== connectionKey) return null;
|
|
69
|
+
await attempts.delete(attemptKey, { ...txOptions, missing: "skip" });
|
|
70
|
+
return protection.open(attempt.payload, `attempt:${connectionKey}:${attemptKey}`);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function pruneExpiredAttempts({ before = Date.now() } = {}) {
|
|
77
|
+
return attempts.deleteWhere({ expiredBy: before }, { ...pruneOptions, returning: "count" });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return Object.freeze({
|
|
81
|
+
withConnection,
|
|
82
|
+
pruneExpiredAttempts
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export { createConnectionStore, requireProtection };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
|
|
2
|
+
import { normalizeOpaqueId } from "@jskit-ai/kernel/shared/support/normalize";
|
|
3
|
+
|
|
4
|
+
// The connector tables. A connection is keyed by a hash of its owner's application and subject and its integration,
|
|
5
|
+
// and an attempt by a hash of its state, not by a workspace or user column: the key is the row's scope, so the tables
|
|
6
|
+
// are public to their owner feature, which reaches them only through keys it computes from a caller's owner and slot.
|
|
7
|
+
|
|
8
|
+
// A hash the store computes; ciphertext is kept exactly as written.
|
|
9
|
+
const key = (column) => Object.freeze({ type: "string", primary: true, required: true, storage: { column } });
|
|
10
|
+
const ciphertext = Object.freeze({ type: "string", noTrim: true });
|
|
11
|
+
|
|
12
|
+
// One owner's connection to one integration: its sealed state, or null while it is disconnected.
|
|
13
|
+
const connectorConnectionResource = defineTableResource({
|
|
14
|
+
name: "connectorConnections",
|
|
15
|
+
tableName: "connector_connections",
|
|
16
|
+
ownership: "public",
|
|
17
|
+
idProperty: "connection_key",
|
|
18
|
+
schema: {
|
|
19
|
+
id: key("connection_key"),
|
|
20
|
+
payload: { ...ciphertext, nullable: true }
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// A pending authorization of one connection, sealed, until it is consumed or expires.
|
|
25
|
+
const connectorAuthorizationAttemptResource = defineTableResource({
|
|
26
|
+
name: "connectorAuthorizationAttempts",
|
|
27
|
+
tableName: "connector_authorization_attempts",
|
|
28
|
+
ownership: "public",
|
|
29
|
+
idProperty: "attempt_key",
|
|
30
|
+
sortableFields: ["expiresAt"],
|
|
31
|
+
schema: {
|
|
32
|
+
id: key("attempt_key"),
|
|
33
|
+
connectionKey: { type: "string", required: true, storage: { column: "connection_key" } },
|
|
34
|
+
expiresAt: { type: "integer", required: true, storage: { column: "expires_at" } },
|
|
35
|
+
payload: { ...ciphertext, required: true }
|
|
36
|
+
},
|
|
37
|
+
searchSchema: {
|
|
38
|
+
connectionKey: { type: "string", actualField: "connectionKey" },
|
|
39
|
+
expiresAfter: { type: "number", actualField: "expiresAt", filterOperator: ">" },
|
|
40
|
+
expiredBy: { type: "number", actualField: "expiresAt", filterOperator: "<=" }
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// The feature's owns entries: each key is an opaque hash, not a numeric id.
|
|
45
|
+
const CONNECTOR_TABLES = Object.freeze([connectorConnectionResource, connectorAuthorizationAttemptResource]
|
|
46
|
+
.map((resource) => Object.freeze({ resource, normalizeId: normalizeOpaqueId })));
|
|
47
|
+
|
|
48
|
+
export { CONNECTOR_TABLES, connectorAuthorizationAttemptResource, connectorConnectionResource };
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
// readable-check: allow no-direct-knex - infrastructure: a command-line script's connection store on the application's Knex
|
|
2
|
+
import { createStandaloneResources } from "@jskit-ai/resource-runtime/server";
|
|
3
|
+
import { CONNECTION_STORE_FEATURE, CONNECTION_STORE_SYSTEM_SCOPE } from "./ConnectionStoreFeature.js";
|
|
4
|
+
import { createConnectionStore, requireProtection } from "./connectionStore.js";
|
|
5
|
+
import { CONNECTOR_TABLES } from "./connectorResources.js";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
|
-
* Connections kept encrypted in the connector tables, each locked in a transaction while it is used
|
|
8
|
+
* Connections kept encrypted in the connector tables, each locked in a transaction while it is used, for code outside
|
|
9
|
+
* a JSKIT server such as a command-line script: the store owns the connector tables through standalone resources on
|
|
10
|
+
* the application's Knex and answers what createConnectionStoreFeature()'s store answers. A JSKIT application
|
|
11
|
+
* uses that feature instead, so its tables have one owner.
|
|
7
12
|
* @param {object} options
|
|
8
13
|
* @param {import("@jskit-ai/database-runtime/server/types").RuntimeKnex} options.knex The application's transactional Knex client.
|
|
9
14
|
* @param {import("./types.js").CredentialProtection} options.protection
|
|
@@ -13,55 +18,31 @@ function createKnexConnectionStore({ knex, protection }) {
|
|
|
13
18
|
if (typeof knex !== "function" || typeof knex.transaction !== "function") {
|
|
14
19
|
throw new TypeError("createKnexConnectionStore requires the application's Knex instance.");
|
|
15
20
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
requireProtection(protection);
|
|
22
|
+
let ready = null;
|
|
23
|
+
async function compose() {
|
|
24
|
+
const { resources, systemContext } = await createStandaloneResources({
|
|
25
|
+
knex,
|
|
26
|
+
feature: CONNECTION_STORE_FEATURE,
|
|
27
|
+
owns: CONNECTOR_TABLES,
|
|
28
|
+
systemScope: CONNECTION_STORE_SYSTEM_SCOPE
|
|
29
|
+
});
|
|
30
|
+
return createConnectionStore({ resources, systemContext, protection });
|
|
19
31
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
await trx("connector_connections").insert({ connection_key: connectionKey })
|
|
26
|
-
.onConflict("connection_key").merge({ connection_key: connectionKey });
|
|
27
|
-
const row = await trx("connector_connections").where({ connection_key: connectionKey }).forUpdate().first();
|
|
28
|
-
return work({
|
|
29
|
-
connection: row.payload ? await protection.open(row.payload, connectionBinding) : null,
|
|
30
|
-
save: async (connection) => {
|
|
31
|
-
const payload = await protection.seal(connection, connectionBinding);
|
|
32
|
-
await trx("connector_connections").where({ connection_key: connectionKey }).update({ payload });
|
|
33
|
-
},
|
|
34
|
-
remove: async () => {
|
|
35
|
-
await trx("connector_connections").where({ connection_key: connectionKey }).update({ payload: null });
|
|
36
|
-
await trx("connector_authorization_attempts").where({ connection_key: connectionKey }).delete();
|
|
37
|
-
},
|
|
38
|
-
putAttempt: async (attempt) => {
|
|
39
|
-
const attemptKey = hash(attempt.state);
|
|
40
|
-
await trx("connector_authorization_attempts").insert({
|
|
41
|
-
attempt_key: attemptKey, connection_key: connectionKey, expires_at: attempt.expiresAt,
|
|
42
|
-
payload: await protection.seal(attempt, `attempt:${connectionKey}:${attemptKey}`)
|
|
43
|
-
});
|
|
44
|
-
},
|
|
45
|
-
latestAttempt: async ({ after }) => {
|
|
46
|
-
const attempt = await trx("connector_authorization_attempts")
|
|
47
|
-
.where({ connection_key: connectionKey }).where("expires_at", ">", after)
|
|
48
|
-
.orderBy("expires_at", "desc").first();
|
|
49
|
-
return attempt ? protection.open(attempt.payload, `attempt:${connectionKey}:${attempt.attempt_key}`) : null;
|
|
50
|
-
},
|
|
51
|
-
consumeAttempt: async (state) => {
|
|
52
|
-
const attemptKey = hash(state);
|
|
53
|
-
const where = { attempt_key: attemptKey, connection_key: connectionKey };
|
|
54
|
-
const attempt = await trx("connector_authorization_attempts").where(where).first();
|
|
55
|
-
if (!attempt) return null;
|
|
56
|
-
await trx("connector_authorization_attempts").where(where).delete();
|
|
57
|
-
return protection.open(attempt.payload, `attempt:${connectionKey}:${attemptKey}`);
|
|
58
|
-
}
|
|
59
|
-
});
|
|
32
|
+
// The handles are built once, on the first call; a failed build is tried again on the next one.
|
|
33
|
+
function store() {
|
|
34
|
+
ready ||= compose().catch((error) => {
|
|
35
|
+
ready = null;
|
|
36
|
+
throw error;
|
|
60
37
|
});
|
|
38
|
+
return ready;
|
|
61
39
|
}
|
|
62
40
|
|
|
63
|
-
async function
|
|
64
|
-
return
|
|
41
|
+
async function withConnection(slot, work) {
|
|
42
|
+
return (await store()).withConnection(slot, work);
|
|
43
|
+
}
|
|
44
|
+
async function pruneExpiredAttempts(options) {
|
|
45
|
+
return (await store()).pruneExpiredAttempts(options);
|
|
65
46
|
}
|
|
66
47
|
|
|
67
48
|
return Object.freeze({
|
package/src/server/storage.js
CHANGED
|
@@ -103,6 +103,22 @@ test("result validation sees the request its operation built and rejects a misma
|
|
|
103
103
|
await assert.rejects(service.invoke({ ...input, operation: "connection.check" }), { code: "connector_response_invalid", statusCode: 502 });
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
test("connections without a store fail at startup, naming the feature to register", async () => {
|
|
107
|
+
const options = {
|
|
108
|
+
configuration: databaseConfiguration({ method: "none" }), providers: [clickhouseProvider],
|
|
109
|
+
authorize: async (context) => context, resolveReference: async () => ""
|
|
110
|
+
};
|
|
111
|
+
// The kernel's startup error names the missing capability and adds the feature's hint.
|
|
112
|
+
assert.throws(() => createCapabilityRuntime({ inputs: { "runtime.logger": silentLogger }, providers: [EventProvider, ActionProvider, createConnectorsFeature(options)] }), {
|
|
113
|
+
message: 'Provider "connectors.core" requires missing capability "connectors.store". Register createConnectionStoreFeature({ protection }) ' +
|
|
114
|
+
"from @jskit-ai/connectors-core/server/storage in your server providers, or pass createConnectorsFeature({ store })."
|
|
115
|
+
});
|
|
116
|
+
assert.throws(() => createConnectionService({ ...options, store: {} }), {
|
|
117
|
+
name: "TypeError",
|
|
118
|
+
message: /^Connections require a connection store with withConnection\(\): register createConnectionStoreFeature\(\{ protection \}\) from @jskit-ai\/connectors-core\/server\/storage/u
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
106
122
|
test("the ordinary credential-free Feature action verifies without resolving references", async () => {
|
|
107
123
|
const contexts = [];
|
|
108
124
|
let actions;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import knexLib from "knex";
|
|
5
|
+
import { ActionProvider } from "@jskit-ai/kernel/server/actions";
|
|
6
|
+
import { EventProvider } from "@jskit-ai/kernel/server/runtime";
|
|
7
|
+
import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
|
|
8
|
+
import { createCapabilityRuntime, defineProvider } from "@jskit-ai/kernel/shared/capabilities";
|
|
9
|
+
import { createTestResourceRuntime } from "@jskit-ai/resource-runtime/test";
|
|
10
|
+
import migration from "../migrations/connectors_core_initial.cjs";
|
|
11
|
+
import { createConnectionStoreFeature, createCredentialProtection } from "../src/server/storage.js";
|
|
12
|
+
|
|
13
|
+
const owner = Object.freeze({ applicationId: "app", subjectId: "user-1" });
|
|
14
|
+
|
|
15
|
+
test("createConnectionStoreFeature owns the connector tables and provides the store on their handles as connectors.store", async (t) => {
|
|
16
|
+
const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true, pool: { min: 1, max: 1 } });
|
|
17
|
+
t.after(() => knex.destroy());
|
|
18
|
+
await migration.up(knex);
|
|
19
|
+
const resources = await createTestResourceRuntime({ knex });
|
|
20
|
+
const protection = createCredentialProtection({ keys: { current: randomBytes(32) }, activeKeyId: "current" });
|
|
21
|
+
let store = null;
|
|
22
|
+
const runtime = createCapabilityRuntime({
|
|
23
|
+
inputs: { "runtime.logger": silentLogger },
|
|
24
|
+
providers: [
|
|
25
|
+
EventProvider,
|
|
26
|
+
ActionProvider,
|
|
27
|
+
defineProvider({ id: "test.resources", provides: { resources: "runtime.resources" }, setup: () => ({ resources }) }),
|
|
28
|
+
createConnectionStoreFeature({ protection }),
|
|
29
|
+
defineProvider({ id: "test.connections", requires: { connections: "connectors.store" }, setup({ connections }) { store = connections; } })
|
|
30
|
+
]
|
|
31
|
+
});
|
|
32
|
+
await runtime.start();
|
|
33
|
+
t.after(() => runtime.shutdown());
|
|
34
|
+
|
|
35
|
+
assert.deepEqual(resources.ownedTables().map(({ tableName, featureId }) => [tableName, featureId]), [
|
|
36
|
+
["connector_connections", "connectors.storage"],
|
|
37
|
+
["connector_authorization_attempts", "connectors.storage"]
|
|
38
|
+
]);
|
|
39
|
+
const slot = { owner, integrationId: "calendar" };
|
|
40
|
+
await store.withConnection(slot, async ({ connection, save, putAttempt }) => {
|
|
41
|
+
assert.equal(connection, null);
|
|
42
|
+
await save({ token: "t1" });
|
|
43
|
+
await putAttempt({ state: "s1", expiresAt: 2_000 });
|
|
44
|
+
});
|
|
45
|
+
assert.deepEqual(await store.withConnection(slot, async ({ connection, latestAttempt, consumeAttempt }) => [
|
|
46
|
+
connection,
|
|
47
|
+
(await latestAttempt({ after: 1_000 }))?.state,
|
|
48
|
+
(await consumeAttempt("s1"))?.state,
|
|
49
|
+
await consumeAttempt("s1")
|
|
50
|
+
]), [{ token: "t1" }, "s1", "s1", null]);
|
|
51
|
+
await store.withConnection(slot, ({ putAttempt }) => putAttempt({ state: "s2", expiresAt: 3_000 }));
|
|
52
|
+
assert.equal(await store.pruneExpiredAttempts({ before: 3_000 }), 1);
|
|
53
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import knexLib from "knex";
|
|
5
|
+
import { requireTestDatabase } from "@jskit-ai/database-runtime/server/testDatabases";
|
|
6
|
+
import migration from "../migrations/connectors_core_initial.cjs";
|
|
7
|
+
import { createCredentialProtection, createKnexConnectionStore } from "../src/server/storage.js";
|
|
8
|
+
|
|
9
|
+
// The SQL connection store's behaviour on MariaDB, through withConnection and pruneExpiredAttempts, pinned before the
|
|
10
|
+
// store moves onto table resources. knexConnectionStore.test.js covers encryption, reopening through another pool,
|
|
11
|
+
// concurrent saves, rollback of a save, owner isolation of credentials and consent attempts when
|
|
12
|
+
// CONNECTORS_TEST_DATABASE_URL is set (it skips without it); this file adds what it leaves open: the values each call
|
|
13
|
+
// answers, which parts of a scope count, rollback of a disconnect and of a failed seal, attempt keys, the newest
|
|
14
|
+
// attempt, and pruning's count and boundary.
|
|
15
|
+
|
|
16
|
+
// The runtime's own MySQL connection settings (database-runtime's resolveKnexConnectionFromEnvironment).
|
|
17
|
+
const RUNTIME_MYSQL = Object.freeze({ supportBigNumbers: true, bigNumberStrings: true, dateStrings: ["DATE"], timezone: "Z" });
|
|
18
|
+
|
|
19
|
+
/** A migrated database of the test's own, beside TEST_DB_NAME, dropped after the test. */
|
|
20
|
+
async function ownDatabase(context, suffix) {
|
|
21
|
+
const name = `${requireTestDatabase(process.env)}_${suffix}`;
|
|
22
|
+
const server = { host: process.env.DB_HOST, port: Number(process.env.DB_PORT) || 3306, user: process.env.DB_USER, password: process.env.DB_PASSWORD };
|
|
23
|
+
const admin = knexLib({ client: "mysql2", connection: server, pool: { min: 0, max: 1 } });
|
|
24
|
+
await admin.raw("DROP DATABASE IF EXISTS ??", [name]);
|
|
25
|
+
await admin.raw("CREATE DATABASE ??", [name]);
|
|
26
|
+
const knex = knexLib({ client: "mysql2", connection: { ...server, database: name, ...RUNTIME_MYSQL }, pool: { min: 0, max: 4 } });
|
|
27
|
+
context.after(async () => {
|
|
28
|
+
await knex.destroy();
|
|
29
|
+
await admin.raw("DROP DATABASE IF EXISTS ??", [name]);
|
|
30
|
+
await admin.destroy();
|
|
31
|
+
});
|
|
32
|
+
await migration.up(knex);
|
|
33
|
+
return knex;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const protection = createCredentialProtection({ keys: { test: randomBytes(32) }, activeKeyId: "test" });
|
|
37
|
+
const owner = Object.freeze({ applicationId: "test-application", subjectId: "user-a" });
|
|
38
|
+
const scope = Object.freeze({ owner, integrationId: "calendar" });
|
|
39
|
+
const read = (store, at) => store.withConnection(at, async ({ connection }) => connection);
|
|
40
|
+
|
|
41
|
+
test("the store needs the application's Knex and credential protection, and answers what each call's work returns", async (context) => {
|
|
42
|
+
requireTestDatabase(process.env);
|
|
43
|
+
const knex = await ownDatabase(context, "da_conn_values");
|
|
44
|
+
assert.throws(() => createKnexConnectionStore({ knex: {}, protection }), { name: "TypeError", message: "createKnexConnectionStore requires the application's Knex instance." });
|
|
45
|
+
assert.throws(() => createKnexConnectionStore({ knex }), { name: "TypeError", message: "Connector storage requires credential protection." });
|
|
46
|
+
assert.throws(() => createKnexConnectionStore({ knex, protection: { seal: async () => "" } }), { name: "TypeError", message: "Connector storage requires credential protection." });
|
|
47
|
+
const store = createKnexConnectionStore({ knex, protection });
|
|
48
|
+
assert.ok(Object.isFrozen(store));
|
|
49
|
+
assert.deepEqual(Object.keys(store), ["withConnection", "pruneExpiredAttempts"]);
|
|
50
|
+
|
|
51
|
+
const answered = await store.withConnection(scope, async (handle) => {
|
|
52
|
+
assert.deepEqual(Object.keys(handle), ["connection", "save", "remove", "putAttempt", "latestAttempt", "consumeAttempt"]);
|
|
53
|
+
assert.equal(handle.connection, null, "a new identity has no connection");
|
|
54
|
+
assert.equal(await handle.save({ status: "connected", tokens: { accessToken: "a" }, at: new Date(Date.UTC(2026, 0, 1)) }), undefined);
|
|
55
|
+
assert.equal(handle.connection, null, "the handle keeps the connection it opened with");
|
|
56
|
+
assert.equal(await handle.putAttempt({ state: "state-1", expiresAt: Date.now() + 60_000 }), undefined);
|
|
57
|
+
return { done: true };
|
|
58
|
+
});
|
|
59
|
+
assert.deepEqual(answered, { done: true });
|
|
60
|
+
assert.deepEqual(await read(store, scope), { status: "connected", tokens: { accessToken: "a" }, at: "2026-01-01T00:00:00.000Z" }, "kept as JSON");
|
|
61
|
+
assert.equal(await store.withConnection(scope, async ({ remove }) => remove()), undefined);
|
|
62
|
+
assert.equal(await read(store, scope), null, "a disconnect leaves no connection");
|
|
63
|
+
assert.equal(await store.withConnection(scope, ({ consumeAttempt }) => consumeAttempt("state-1")), null, "and no attempts");
|
|
64
|
+
await store.withConnection(scope, ({ save }) => save({ status: "reconnected" }));
|
|
65
|
+
assert.deepEqual(await read(store, scope), { status: "reconnected" }, "an identity connects again after a disconnect");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("a connection is the owner's application, subject and integration, and nothing else in the scope", async (context) => {
|
|
69
|
+
requireTestDatabase(process.env);
|
|
70
|
+
const store = createKnexConnectionStore({ knex: await ownDatabase(context, "da_conn_scope"), protection });
|
|
71
|
+
await store.withConnection(scope, ({ save }) => save({ account: "calendar-a" }));
|
|
72
|
+
await store.withConnection({ owner, integrationId: "mail" }, ({ save }) => save({ account: "mail-a" }));
|
|
73
|
+
|
|
74
|
+
assert.deepEqual(await read(store, scope), { account: "calendar-a" });
|
|
75
|
+
assert.deepEqual(await read(store, { owner, integrationId: "mail" }), { account: "mail-a" }, "each integration of an owner is its own");
|
|
76
|
+
assert.equal(await read(store, { owner: { ...owner, subjectId: "user-b" }, integrationId: "calendar" }), null);
|
|
77
|
+
assert.equal(await read(store, { owner: { ...owner, applicationId: "other-application" }, integrationId: "calendar" }), null);
|
|
78
|
+
// Fields of the owner other than applicationId and subjectId do not separate connections.
|
|
79
|
+
assert.deepEqual(await read(store, { owner: { ...owner, workspaceId: "workspace-9", role: "admin" }, integrationId: "calendar" }), { account: "calendar-a" });
|
|
80
|
+
assert.deepEqual(await read(store, { owner: { subjectId: owner.subjectId, applicationId: owner.applicationId }, integrationId: "calendar" }), { account: "calendar-a" });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a failed disconnect or a failed seal keeps the connection and its attempts as they were", async (context) => {
|
|
84
|
+
requireTestDatabase(process.env);
|
|
85
|
+
const knex = await ownDatabase(context, "da_conn_rollback");
|
|
86
|
+
const store = createKnexConnectionStore({ knex, protection });
|
|
87
|
+
const attempt = { state: "pending-state", codeVerifier: "verifier", expiresAt: Date.now() + 60_000 };
|
|
88
|
+
await store.withConnection(scope, async ({ save, putAttempt }) => {
|
|
89
|
+
await save({ version: 1 });
|
|
90
|
+
await putAttempt(attempt);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const failure = new Error("revocation failed");
|
|
94
|
+
await assert.rejects(store.withConnection(scope, async ({ remove }) => {
|
|
95
|
+
await remove();
|
|
96
|
+
throw failure;
|
|
97
|
+
}), (error) => error === failure);
|
|
98
|
+
assert.deepEqual(await read(store, scope), { version: 1 });
|
|
99
|
+
assert.deepEqual(await store.withConnection(scope, ({ latestAttempt }) => latestAttempt({ after: Date.now() })), attempt);
|
|
100
|
+
|
|
101
|
+
const sealFailure = new Error("vault unavailable");
|
|
102
|
+
const brokenWriter = createKnexConnectionStore({ knex, protection: { ...protection, seal: async () => { throw sealFailure; } } });
|
|
103
|
+
await assert.rejects(brokenWriter.withConnection(scope, async ({ save }) => {
|
|
104
|
+
await save({ version: 2 });
|
|
105
|
+
}), (error) => error === sealFailure);
|
|
106
|
+
assert.deepEqual(await read(store, scope), { version: 1 });
|
|
107
|
+
assert.deepEqual(await store.withConnection(scope, ({ consumeAttempt }) => consumeAttempt(attempt.state)), attempt);
|
|
108
|
+
|
|
109
|
+
// A connection whose first work fails leaves no row: its creation rolls back with the work.
|
|
110
|
+
const [{ rows }] = await knex("connector_connections").count({ rows: "*" });
|
|
111
|
+
await assert.rejects(store.withConnection({ ...scope, integrationId: "never-kept" }, async ({ save }) => {
|
|
112
|
+
await save({ version: 1 });
|
|
113
|
+
throw failure;
|
|
114
|
+
}), (error) => error === failure);
|
|
115
|
+
assert.deepEqual(await knex("connector_connections").count({ rows: "*" }), [{ rows }]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("first uses of new connections at once each create their row once and run their work once", { timeout: 20_000 }, async (context) => {
|
|
119
|
+
requireTestDatabase(process.env);
|
|
120
|
+
const knex = await ownDatabase(context, "da_conn_race");
|
|
121
|
+
const store = createKnexConnectionStore({ knex, protection });
|
|
122
|
+
const slots = ["one", "two", "three"].map((integrationId) => ({ ...scope, integrationId }));
|
|
123
|
+
let runs = 0;
|
|
124
|
+
const saved = await Promise.all(slots.flatMap((slot) => [1, 2].map(() => store.withConnection(slot, async ({ connection, save }) => {
|
|
125
|
+
runs += 1;
|
|
126
|
+
const version = (connection?.version ?? 0) + 1;
|
|
127
|
+
await save({ version });
|
|
128
|
+
return version;
|
|
129
|
+
}))));
|
|
130
|
+
assert.equal(runs, 6, "each work ran once");
|
|
131
|
+
for (const [index, slot] of slots.entries()) {
|
|
132
|
+
assert.deepEqual(saved.slice(index * 2, index * 2 + 2).sort(), [1, 2], slot.integrationId);
|
|
133
|
+
assert.deepEqual(await read(store, slot), { version: 2 }, slot.integrationId);
|
|
134
|
+
}
|
|
135
|
+
const [{ rows }] = await knex("connector_connections").count({ rows: "*" });
|
|
136
|
+
assert.equal(Number(rows), 3);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("an attempt is keyed by its state alone, the newest live one is answered, and consuming takes it once", async (context) => {
|
|
140
|
+
requireTestDatabase(process.env);
|
|
141
|
+
const store = createKnexConnectionStore({ knex: await ownDatabase(context, "da_conn_attempts"), protection });
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
const older = { state: "older", expiresAt: now + 10_000, owner, integrationId: "calendar" };
|
|
144
|
+
const newer = { state: "newer", expiresAt: now + 20_000, owner, integrationId: "calendar" };
|
|
145
|
+
await store.withConnection(scope, async ({ putAttempt, latestAttempt }) => {
|
|
146
|
+
assert.equal(await latestAttempt({ after: now }), null);
|
|
147
|
+
await putAttempt(newer);
|
|
148
|
+
await putAttempt(older);
|
|
149
|
+
assert.deepEqual(await latestAttempt({ after: now }), newer, "the attempt that expires last, whatever the order it was put in");
|
|
150
|
+
assert.deepEqual(await latestAttempt({ after: older.expiresAt }), newer);
|
|
151
|
+
assert.equal(await latestAttempt({ after: newer.expiresAt }), null, "an attempt expiring exactly at `after` is not live");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// The same state twice fails with the driver's own duplicate-key error, for the same connection and, because an
|
|
155
|
+
// attempt's key is its state's hash alone, for another owner's connection too.
|
|
156
|
+
for (const at of [scope, { owner: { ...owner, subjectId: "user-b" }, integrationId: "calendar" }]) {
|
|
157
|
+
await assert.rejects(store.withConnection(at, ({ putAttempt }) => putAttempt({ state: "newer", expiresAt: now + 30_000 })), (error) => {
|
|
158
|
+
assert.equal(error.code, "ER_DUP_ENTRY");
|
|
159
|
+
return true;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
assert.deepEqual(await store.withConnection(scope, ({ consumeAttempt }) => consumeAttempt("older")), older);
|
|
164
|
+
assert.equal(await store.withConnection(scope, ({ consumeAttempt }) => consumeAttempt("older")), null, "taken once");
|
|
165
|
+
assert.deepEqual(await store.withConnection(scope, ({ latestAttempt }) => latestAttempt({ after: now })), newer, "the other attempt stays");
|
|
166
|
+
// A consumed state can be put again.
|
|
167
|
+
await store.withConnection(scope, ({ putAttempt }) => putAttempt({ state: "older", expiresAt: now + 5_000 }));
|
|
168
|
+
assert.equal((await store.withConnection(scope, ({ consumeAttempt }) => consumeAttempt("older"))).expiresAt, now + 5_000);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("pruning removes every connection's attempts that expired by `before`, inclusive, and answers how many", async (context) => {
|
|
172
|
+
requireTestDatabase(process.env);
|
|
173
|
+
const store = createKnexConnectionStore({ knex: await ownDatabase(context, "da_conn_prune"), protection });
|
|
174
|
+
const before = Date.now() + 60_000;
|
|
175
|
+
const otherScope = { owner: { ...owner, subjectId: "user-b" }, integrationId: "mail" };
|
|
176
|
+
assert.equal(await store.pruneExpiredAttempts({ before }), 0, "nothing to prune");
|
|
177
|
+
await store.withConnection(scope, async ({ putAttempt }) => {
|
|
178
|
+
await putAttempt({ state: "a-expired", expiresAt: before - 1 });
|
|
179
|
+
await putAttempt({ state: "a-boundary", expiresAt: before });
|
|
180
|
+
await putAttempt({ state: "a-live", expiresAt: before + 1 });
|
|
181
|
+
});
|
|
182
|
+
await store.withConnection(otherScope, ({ putAttempt }) => putAttempt({ state: "b-expired", expiresAt: before - 5_000 }));
|
|
183
|
+
|
|
184
|
+
assert.equal(await store.pruneExpiredAttempts({ before }), 3);
|
|
185
|
+
assert.equal(await store.pruneExpiredAttempts({ before }), 0, "pruning again finds nothing");
|
|
186
|
+
for (const state of ["a-expired", "a-boundary"]) {
|
|
187
|
+
assert.equal(await store.withConnection(scope, ({ consumeAttempt }) => consumeAttempt(state)), null, state);
|
|
188
|
+
}
|
|
189
|
+
assert.equal(await store.withConnection(otherScope, ({ consumeAttempt }) => consumeAttempt("b-expired")), null);
|
|
190
|
+
assert.equal((await store.withConnection(scope, ({ consumeAttempt }) => consumeAttempt("a-live"))).state, "a-live");
|
|
191
|
+
|
|
192
|
+
// Without `before`, now is the boundary.
|
|
193
|
+
await store.withConnection(scope, async ({ putAttempt }) => {
|
|
194
|
+
await putAttempt({ state: "past", expiresAt: Date.now() - 1_000 });
|
|
195
|
+
await putAttempt({ state: "future", expiresAt: Date.now() + 60_000 });
|
|
196
|
+
});
|
|
197
|
+
assert.equal(await store.pruneExpiredAttempts(), 1);
|
|
198
|
+
assert.equal((await store.withConnection(scope, ({ latestAttempt }) => latestAttempt({ after: 0 }))).state, "future");
|
|
199
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { createInstalledRuntime } from "@jskit-ai/kernel/server/platform";
|
|
7
|
+
import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
|
|
8
|
+
|
|
9
|
+
const TABLE_RUNTIME = ["@jskit-ai/database-runtime", "@jskit-ai/resource-runtime"];
|
|
10
|
+
const readManifest = async (url) => JSON.parse(await readFile(url, "utf8"));
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* An app installed as npm installs it: each package's manifest under node_modules, by name, and nothing else, so no
|
|
14
|
+
* package of the monorepo is reached by accident.
|
|
15
|
+
* @param {import("node:test").TestContext} t
|
|
16
|
+
* @param {object[]} manifests
|
|
17
|
+
*/
|
|
18
|
+
async function installedApp(t, manifests) {
|
|
19
|
+
const appRoot = await mkdtemp(path.join(tmpdir(), "connectors-core-without-database-"));
|
|
20
|
+
t.after(() => rm(appRoot, { recursive: true, force: true }));
|
|
21
|
+
for (const manifest of manifests) {
|
|
22
|
+
const packageRoot = path.join(appRoot, "node_modules", ...manifest.name.split("/"));
|
|
23
|
+
await mkdir(packageRoot, { recursive: true });
|
|
24
|
+
await writeFile(path.join(packageRoot, "package.json"), JSON.stringify(manifest));
|
|
25
|
+
}
|
|
26
|
+
const [own] = manifests;
|
|
27
|
+
await writeFile(path.join(appRoot, "package.json"), JSON.stringify({
|
|
28
|
+
name: "app-without-database",
|
|
29
|
+
private: true,
|
|
30
|
+
dependencies: { [own.name]: own.version }
|
|
31
|
+
}));
|
|
32
|
+
return appRoot;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test("an app without a database installs @jskit-ai/connectors-core without the table runtime, composes it and starts", async (t) => {
|
|
36
|
+
const manifest = await readManifest(new URL("../package.json", import.meta.url));
|
|
37
|
+
// npm installs a package's dependencies, never its optional peers: the table runtime comes with an app's database.
|
|
38
|
+
for (const name of TABLE_RUNTIME) {
|
|
39
|
+
assert.equal(manifest.dependencies?.[name], undefined, `${name} is not a dependency`);
|
|
40
|
+
assert.equal(manifest.peerDependenciesMeta?.[name]?.optional, true, `${name} is an optional peer`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const started = await createInstalledRuntime({ appRoot: await installedApp(t, [manifest]), logger: silentLogger });
|
|
44
|
+
assert.ok(started.packageOrder.includes(manifest.name));
|
|
45
|
+
assert.equal(started.packageOrder.some((name) => TABLE_RUNTIME.includes(name)), false);
|
|
46
|
+
|
|
47
|
+
// Installed as a dependency, the table runtime would need a database driver the app does not have.
|
|
48
|
+
const databaseRuntime = await readManifest(new URL("../../database-runtime/package.json", import.meta.url));
|
|
49
|
+
await assert.rejects(
|
|
50
|
+
createInstalledRuntime({ appRoot: await installedApp(t, [manifest, databaseRuntime]), logger: silentLogger }),
|
|
51
|
+
/requires capability runtime\.database\.driver, but no installed package provides it/u
|
|
52
|
+
);
|
|
53
|
+
});
|