@simonsbs/keylore 1.0.0-rc4 → 1.0.0-rc5
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/.env.example +5 -1
- package/README.md +12 -8
- package/bin/keylore-http.js +10 -2
- package/dist/app.js +2 -2
- package/dist/config.js +11 -7
- package/dist/http-service.js +167 -0
- package/dist/storage/database.js +85 -2
- package/dist/storage/in-memory-database.js +10 -1
- package/dist/storage/migrations.js +2 -2
- package/package.json +2 -1
package/.env.example
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
KEYLORE_DATABASE_MODE=local
|
|
2
|
+
KEYLORE_LOCAL_DATABASE_FILE=keylore.db
|
|
3
|
+
# Optional advanced override:
|
|
4
|
+
# KEYLORE_DATABASE_MODE=postgres
|
|
5
|
+
# KEYLORE_DATABASE_URL=postgresql://keylore:keylore@127.0.0.1:5432/keylore
|
|
2
6
|
KEYLORE_DATABASE_POOL_MAX=10
|
|
3
7
|
KEYLORE_AUTH_CLIENTS_FILE=auth-clients.json
|
|
4
8
|
KEYLORE_LOCAL_SECRETS_FILE=local-secrets.enc.json
|
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ This repository is incubating privately today, but it is structured to be publis
|
|
|
22
22
|
- MCP server for `stdio` and Streamable HTTP
|
|
23
23
|
- metadata-only catalogue search and retrieval tools
|
|
24
24
|
- default-deny policy engine with domain and operation constraints
|
|
25
|
-
-
|
|
25
|
+
- file-backed local persistence by default with startup migrations, plus explicit PostgreSQL support for advanced deployments
|
|
26
26
|
- OAuth-style client credentials token issuance for remote HTTP and MCP access
|
|
27
27
|
- PKCE-bound `authorization_code` plus rotating `refresh_token` support for interactive public or confidential clients
|
|
28
28
|
- protected-resource metadata for REST and MCP surfaces
|
|
@@ -64,13 +64,13 @@ This repository is incubating privately today, but it is structured to be publis
|
|
|
64
64
|
|
|
65
65
|
## What is intentionally deferred
|
|
66
66
|
|
|
67
|
-
The full `KeyLore.md` specification is broader than a sane `v1.0.0-
|
|
67
|
+
The full `KeyLore.md` specification is broader than a sane `v1.0.0-rc5` delivery. The main remaining work before `v1.0.0` is:
|
|
68
68
|
|
|
69
69
|
- public release polish and final operator documentation cleanup
|
|
70
70
|
|
|
71
71
|
Those items are tracked in [docs/roadmap.md](/home/simon/keylore/docs/roadmap.md) and mapped back to the spec in [docs/keylore-spec-map.md](/home/simon/keylore/docs/keylore-spec-map.md).
|
|
72
72
|
|
|
73
|
-
The active post-`v1.0.0-
|
|
73
|
+
The active post-`v1.0.0-rc5` refocus is documented in [docs/core-mode-plan.md](/home/simon/keylore/docs/core-mode-plan.md): make the default user journey "add secret, add context, connect MCP, use it" and push broader operator features behind an advanced path.
|
|
74
74
|
|
|
75
75
|
The handoff from local core mode to advanced self-hosted mode is documented in [docs/production-handoff.md](/home/simon/keylore/docs/production-handoff.md).
|
|
76
76
|
|
|
@@ -88,14 +88,16 @@ npm install
|
|
|
88
88
|
npm run quickstart
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
+
That starts KeyLore in the background. Use `keylore-http status`, `keylore-http stop`, and `keylore-http restart` to manage it later. Use `keylore-http run` only when you intentionally want the server attached to the terminal for debugging.
|
|
92
|
+
|
|
91
93
|
For a clean Linux VM install from npm instead of cloning the repo:
|
|
92
94
|
|
|
93
95
|
```bash
|
|
94
96
|
npm install -g @simonsbs/keylore@next
|
|
95
|
-
keylore-http
|
|
97
|
+
keylore-http start
|
|
96
98
|
```
|
|
97
99
|
|
|
98
|
-
That starts KeyLore from the packaged migrations and seed data
|
|
100
|
+
That starts KeyLore from the packaged migrations and seed data with no Docker or external PostgreSQL required. Writable state defaults to `~/.keylore`.
|
|
99
101
|
|
|
100
102
|
To simulate a brand-new user install on the same machine without reusing your normal checkout or shell environment:
|
|
101
103
|
|
|
@@ -105,8 +107,8 @@ npm run ops:fresh-user-env
|
|
|
105
107
|
|
|
106
108
|
That launches an isolated disposable user, a fresh clone, and a separate KeyLore UI port for onboarding and MCP testing. By default it clones from the current local repo source so it also works while the repo is private.
|
|
107
109
|
|
|
108
|
-
This
|
|
109
|
-
If KeyLore is already running locally
|
|
110
|
+
This boots KeyLore at `http://127.0.0.1:8787` with a local embedded database and encrypted local secret store.
|
|
111
|
+
If KeyLore is already running locally, the command reuses the existing background instance instead of failing.
|
|
110
112
|
|
|
111
113
|
3. Open KeyLore in your browser:
|
|
112
114
|
|
|
@@ -164,12 +166,14 @@ KEYLORE_SANDBOX_COMMAND_ALLOWLIST=/usr/bin/env,node
|
|
|
164
166
|
|
|
165
167
|
## Advanced local usage
|
|
166
168
|
|
|
167
|
-
|
|
169
|
+
If you want production-style external persistence locally, start PostgreSQL first:
|
|
168
170
|
|
|
169
171
|
```bash
|
|
170
172
|
npm run db:up
|
|
171
173
|
```
|
|
172
174
|
|
|
175
|
+
Then either set `KEYLORE_DATABASE_MODE=postgres` and `KEYLORE_DATABASE_URL=...` in `.env`, or export them for one run.
|
|
176
|
+
|
|
173
177
|
Start the HTTP server directly:
|
|
174
178
|
|
|
175
179
|
```bash
|
package/bin/keylore-http.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const binDir = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const runtimeEntryPath = path.resolve(binDir, "../dist/index.js");
|
|
7
|
+
const serviceManagerPath = path.resolve(binDir, "../dist/http-service.js");
|
|
8
|
+
|
|
9
|
+
const { runHttpServiceCommand } = await import(serviceManagerPath);
|
|
10
|
+
const exitCode = await runHttpServiceCommand(process.argv.slice(2), runtimeEntryPath);
|
|
11
|
+
process.exit(exitCode);
|
package/dist/app.js
CHANGED
|
@@ -38,13 +38,13 @@ import { TenantService } from "./services/tenant-service.js";
|
|
|
38
38
|
import { TraceExportService } from "./services/trace-export-service.js";
|
|
39
39
|
import { TraceService } from "./services/trace-service.js";
|
|
40
40
|
import { bootstrapFromFiles } from "./storage/bootstrap.js";
|
|
41
|
-
import {
|
|
41
|
+
import { createSqlDatabase } from "./storage/database.js";
|
|
42
42
|
import { runMigrations } from "./storage/migrations.js";
|
|
43
43
|
import { SandboxRunner } from "./runtime/sandbox-runner.js";
|
|
44
44
|
export async function createKeyLoreApp() {
|
|
45
45
|
const config = loadConfig();
|
|
46
46
|
const logger = pino({ name: config.appName, level: config.logLevel });
|
|
47
|
-
const database =
|
|
47
|
+
const database = await createSqlDatabase(config);
|
|
48
48
|
const telemetry = new TelemetryService();
|
|
49
49
|
const traces = new TraceService(config.traceCaptureEnabled, config.traceRecentSpanLimit);
|
|
50
50
|
const traceExports = new TraceExportService(config.traceExportUrl, config.traceExportAuthHeader, config.traceExportBatchSize, config.traceExportIntervalMs, config.traceExportTimeoutMs, telemetry);
|
package/dist/config.js
CHANGED
|
@@ -3,7 +3,6 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import * as z from "zod/v4";
|
|
6
|
-
const LOCAL_DATABASE_URL = "postgresql://keylore:keylore@127.0.0.1:5432/keylore";
|
|
7
6
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
7
|
const LOCAL_ADMIN_CLIENT_ID = "keylore-admin-local";
|
|
9
8
|
const LOCAL_ADMIN_CLIENT_SECRET = "keylore-local-admin";
|
|
@@ -73,9 +72,6 @@ function hydrateEnvironment(cwd) {
|
|
|
73
72
|
...fileEnv,
|
|
74
73
|
...process.env,
|
|
75
74
|
};
|
|
76
|
-
if (isMissing(effectiveEnv.KEYLORE_DATABASE_URL)) {
|
|
77
|
-
effectiveEnv.KEYLORE_DATABASE_URL = LOCAL_DATABASE_URL;
|
|
78
|
-
}
|
|
79
75
|
const environment = effectiveEnv.KEYLORE_ENVIRONMENT?.trim() || "development";
|
|
80
76
|
const httpHost = effectiveEnv.KEYLORE_HTTP_HOST?.trim() || "127.0.0.1";
|
|
81
77
|
const bootstrapFromFiles = effectiveEnv.KEYLORE_BOOTSTRAP_FROM_FILES !== "false";
|
|
@@ -113,14 +109,16 @@ function resolveDefaultDataDir(cwd) {
|
|
|
113
109
|
return path.join(os.homedir(), ".keylore");
|
|
114
110
|
}
|
|
115
111
|
const envSchema = z.object({
|
|
112
|
+
KEYLORE_DATABASE_MODE: z.enum(["local", "postgres"]).optional(),
|
|
116
113
|
KEYLORE_DATA_DIR: z.string().optional(),
|
|
117
114
|
KEYLORE_CATALOG_FILE: z.string().optional(),
|
|
118
115
|
KEYLORE_POLICY_FILE: z.string().optional(),
|
|
119
116
|
KEYLORE_AUTH_CLIENTS_FILE: z.string().optional(),
|
|
120
117
|
KEYLORE_MIGRATIONS_DIR: z.string().optional(),
|
|
118
|
+
KEYLORE_LOCAL_DATABASE_FILE: z.string().optional(),
|
|
121
119
|
KEYLORE_LOCAL_SECRETS_FILE: z.string().optional(),
|
|
122
120
|
KEYLORE_LOCAL_SECRETS_KEY_FILE: z.string().optional(),
|
|
123
|
-
KEYLORE_DATABASE_URL:
|
|
121
|
+
KEYLORE_DATABASE_URL: optionalString,
|
|
124
122
|
KEYLORE_DATABASE_POOL_MAX: z.coerce.number().int().min(1).max(100).default(10),
|
|
125
123
|
KEYLORE_HTTP_HOST: z.string().default("127.0.0.1"),
|
|
126
124
|
KEYLORE_HTTP_PORT: z.coerce.number().int().min(1).max(65535).default(8787),
|
|
@@ -196,22 +194,28 @@ export function loadConfig(cwd = process.cwd()) {
|
|
|
196
194
|
const env = envSchema.parse(hydrated.env);
|
|
197
195
|
const runtimeRoot = resolveRuntimeRoot(cwd);
|
|
198
196
|
const dataDir = path.resolve(env.KEYLORE_DATA_DIR ?? resolveDefaultDataDir(cwd));
|
|
197
|
+
const databaseMode = env.KEYLORE_DATABASE_MODE ?? (env.KEYLORE_DATABASE_URL ? "postgres" : "local");
|
|
199
198
|
const publicBaseUrl = env.KEYLORE_PUBLIC_BASE_URL ?? `http://${env.KEYLORE_HTTP_HOST}:${env.KEYLORE_HTTP_PORT}`;
|
|
200
199
|
const oauthIssuerUrl = env.KEYLORE_OAUTH_ISSUER_URL ?? `${publicBaseUrl}/oauth`;
|
|
201
200
|
const localQuickstartEnabled = env.KEYLORE_ENVIRONMENT !== "production" &&
|
|
202
201
|
env.KEYLORE_BOOTSTRAP_FROM_FILES &&
|
|
203
202
|
isLoopbackHost(env.KEYLORE_HTTP_HOST);
|
|
203
|
+
if (databaseMode === "postgres" && isMissing(env.KEYLORE_DATABASE_URL)) {
|
|
204
|
+
throw new Error("KEYLORE_DATABASE_URL is required when KEYLORE_DATABASE_MODE=postgres.");
|
|
205
|
+
}
|
|
204
206
|
return {
|
|
205
207
|
appName: "keylore",
|
|
206
|
-
version: "1.0.0-
|
|
208
|
+
version: "1.0.0-rc5",
|
|
207
209
|
dataDir,
|
|
208
210
|
bootstrapCatalogPath: path.resolve(runtimeRoot, "data", env.KEYLORE_CATALOG_FILE ?? "catalog.json"),
|
|
209
211
|
bootstrapPolicyPath: path.resolve(runtimeRoot, "data", env.KEYLORE_POLICY_FILE ?? "policies.json"),
|
|
210
212
|
bootstrapAuthClientsPath: path.resolve(runtimeRoot, "data", env.KEYLORE_AUTH_CLIENTS_FILE ?? "auth-clients.json"),
|
|
211
213
|
migrationsDir: path.resolve(runtimeRoot, env.KEYLORE_MIGRATIONS_DIR ?? "migrations"),
|
|
214
|
+
databaseMode,
|
|
215
|
+
localDatabasePath: path.resolve(dataDir, env.KEYLORE_LOCAL_DATABASE_FILE ?? "keylore.db"),
|
|
212
216
|
localSecretsFilePath: path.resolve(dataDir, env.KEYLORE_LOCAL_SECRETS_FILE ?? "local-secrets.enc.json"),
|
|
213
217
|
localSecretsKeyPath: path.resolve(dataDir, env.KEYLORE_LOCAL_SECRETS_KEY_FILE ?? "local-secrets.key"),
|
|
214
|
-
databaseUrl: env.KEYLORE_DATABASE_URL,
|
|
218
|
+
databaseUrl: env.KEYLORE_DATABASE_URL || undefined,
|
|
215
219
|
databasePoolMax: env.KEYLORE_DATABASE_POOL_MAX,
|
|
216
220
|
httpHost: env.KEYLORE_HTTP_HOST,
|
|
217
221
|
httpPort: env.KEYLORE_HTTP_PORT,
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
function resolvePaths() {
|
|
7
|
+
const serviceDir = path.join(os.homedir(), ".keylore", "service");
|
|
8
|
+
return {
|
|
9
|
+
serviceDir,
|
|
10
|
+
metadataFile: path.join(serviceDir, "http-service.json"),
|
|
11
|
+
logFile: path.join(serviceDir, "http-service.log"),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
async function ensureServiceDir(paths) {
|
|
15
|
+
await fsp.mkdir(paths.serviceDir, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
async function readMetadata(paths) {
|
|
18
|
+
try {
|
|
19
|
+
const raw = await fsp.readFile(paths.metadataFile, "utf8");
|
|
20
|
+
return JSON.parse(raw);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function writeMetadata(paths, metadata) {
|
|
30
|
+
await ensureServiceDir(paths);
|
|
31
|
+
await fsp.writeFile(paths.metadataFile, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
|
32
|
+
}
|
|
33
|
+
async function removeMetadata(paths) {
|
|
34
|
+
await fsp.rm(paths.metadataFile, { force: true });
|
|
35
|
+
}
|
|
36
|
+
function isRunning(pid) {
|
|
37
|
+
try {
|
|
38
|
+
process.kill(pid, 0);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function shouldPreferCurrentCwd(cwd) {
|
|
46
|
+
return (fs.existsSync(path.join(cwd, ".env")) ||
|
|
47
|
+
(fs.existsSync(path.join(cwd, "data")) && fs.existsSync(path.join(cwd, "migrations"))));
|
|
48
|
+
}
|
|
49
|
+
async function resolveLaunchCwd(paths, preferredCwd) {
|
|
50
|
+
const cwd = process.cwd();
|
|
51
|
+
if (shouldPreferCurrentCwd(cwd)) {
|
|
52
|
+
return cwd;
|
|
53
|
+
}
|
|
54
|
+
if (preferredCwd) {
|
|
55
|
+
return preferredCwd;
|
|
56
|
+
}
|
|
57
|
+
const metadata = await readMetadata(paths);
|
|
58
|
+
if (metadata?.cwd) {
|
|
59
|
+
return metadata.cwd;
|
|
60
|
+
}
|
|
61
|
+
return cwd;
|
|
62
|
+
}
|
|
63
|
+
async function stopPid(pid) {
|
|
64
|
+
try {
|
|
65
|
+
process.kill(pid, "SIGTERM");
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const deadline = Date.now() + 10_000;
|
|
71
|
+
while (Date.now() < deadline) {
|
|
72
|
+
if (!isRunning(pid)) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
process.kill(pid, "SIGKILL");
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export async function runHttpServiceCommand(argv, runtimeEntryPath) {
|
|
85
|
+
const command = argv[0] ?? "start";
|
|
86
|
+
const paths = resolvePaths();
|
|
87
|
+
if (command === "run") {
|
|
88
|
+
const child = spawn(process.execPath, [runtimeEntryPath, "--transport", "http"], {
|
|
89
|
+
cwd: await resolveLaunchCwd(paths),
|
|
90
|
+
env: process.env,
|
|
91
|
+
stdio: "inherit",
|
|
92
|
+
});
|
|
93
|
+
const exitCode = await new Promise((resolve) => {
|
|
94
|
+
child.on("exit", (code, signal) => {
|
|
95
|
+
if (signal) {
|
|
96
|
+
resolve(1);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
resolve(code ?? 0);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
return exitCode;
|
|
103
|
+
}
|
|
104
|
+
if (command === "status") {
|
|
105
|
+
const metadata = await readMetadata(paths);
|
|
106
|
+
if (!metadata || !isRunning(metadata.pid)) {
|
|
107
|
+
await removeMetadata(paths);
|
|
108
|
+
process.stdout.write("KeyLore HTTP is not running.\n");
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
process.stdout.write(`KeyLore HTTP is running.\nPID: ${metadata.pid}\nWorking directory: ${metadata.cwd}\nLog: ${metadata.logFile}\nStarted: ${metadata.startedAt}\n`);
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
if (command === "stop") {
|
|
115
|
+
const metadata = await readMetadata(paths);
|
|
116
|
+
if (!metadata || !isRunning(metadata.pid)) {
|
|
117
|
+
await removeMetadata(paths);
|
|
118
|
+
process.stdout.write("KeyLore HTTP is not running.\n");
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
await stopPid(metadata.pid);
|
|
122
|
+
await removeMetadata(paths);
|
|
123
|
+
process.stdout.write("KeyLore HTTP stopped.\n");
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
let rememberedCwd;
|
|
127
|
+
if (command === "restart") {
|
|
128
|
+
const metadata = await readMetadata(paths);
|
|
129
|
+
rememberedCwd = metadata?.cwd;
|
|
130
|
+
if (metadata && isRunning(metadata.pid)) {
|
|
131
|
+
await stopPid(metadata.pid);
|
|
132
|
+
await removeMetadata(paths);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
await removeMetadata(paths);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else if (command !== "start") {
|
|
139
|
+
process.stderr.write("Usage: keylore-http [start|stop|restart|status|run]\n");
|
|
140
|
+
return 1;
|
|
141
|
+
}
|
|
142
|
+
const existing = await readMetadata(paths);
|
|
143
|
+
if (existing && isRunning(existing.pid)) {
|
|
144
|
+
process.stdout.write(`KeyLore HTTP is already running in the background.\nPID: ${existing.pid}\nLog: ${existing.logFile}\n`);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
await ensureServiceDir(paths);
|
|
148
|
+
const cwd = await resolveLaunchCwd(paths, rememberedCwd);
|
|
149
|
+
const logHandle = await fsp.open(paths.logFile, "a");
|
|
150
|
+
const child = spawn(process.execPath, [runtimeEntryPath, "--transport", "http"], {
|
|
151
|
+
cwd,
|
|
152
|
+
env: process.env,
|
|
153
|
+
detached: true,
|
|
154
|
+
stdio: ["ignore", logHandle.fd, logHandle.fd],
|
|
155
|
+
});
|
|
156
|
+
child.unref();
|
|
157
|
+
await logHandle.close();
|
|
158
|
+
const metadata = {
|
|
159
|
+
cwd,
|
|
160
|
+
pid: child.pid ?? 0,
|
|
161
|
+
logFile: paths.logFile,
|
|
162
|
+
startedAt: new Date().toISOString(),
|
|
163
|
+
};
|
|
164
|
+
await writeMetadata(paths, metadata);
|
|
165
|
+
process.stdout.write(`KeyLore HTTP started in the background.\nPID: ${metadata.pid}\nWorking directory: ${metadata.cwd}\nLog: ${metadata.logFile}\n`);
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
package/dist/storage/database.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
1
4
|
import { Pool } from "pg";
|
|
2
5
|
class PostgresDatabase {
|
|
3
6
|
pool;
|
|
@@ -7,11 +10,20 @@ class PostgresDatabase {
|
|
|
7
10
|
async query(text, params) {
|
|
8
11
|
return this.pool.query(text, params);
|
|
9
12
|
}
|
|
13
|
+
async exec(text) {
|
|
14
|
+
await this.pool.query(text);
|
|
15
|
+
}
|
|
10
16
|
async withTransaction(fn) {
|
|
11
17
|
const client = await this.pool.connect();
|
|
12
18
|
try {
|
|
13
19
|
await client.query("BEGIN");
|
|
14
|
-
const
|
|
20
|
+
const transactionalClient = {
|
|
21
|
+
query: (text, params) => client.query(text, params),
|
|
22
|
+
exec: async (text) => {
|
|
23
|
+
await client.query(text);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
const result = await fn(transactionalClient);
|
|
15
27
|
await client.query("COMMIT");
|
|
16
28
|
return result;
|
|
17
29
|
}
|
|
@@ -30,10 +42,81 @@ class PostgresDatabase {
|
|
|
30
42
|
await this.pool.end();
|
|
31
43
|
}
|
|
32
44
|
}
|
|
33
|
-
|
|
45
|
+
class LocalDatabase {
|
|
46
|
+
database;
|
|
47
|
+
queue = Promise.resolve();
|
|
48
|
+
constructor(database) {
|
|
49
|
+
this.database = database;
|
|
50
|
+
}
|
|
51
|
+
enqueue(fn) {
|
|
52
|
+
const next = this.queue.then(fn, fn);
|
|
53
|
+
this.queue = next.then(() => undefined, () => undefined);
|
|
54
|
+
return next;
|
|
55
|
+
}
|
|
56
|
+
normalize(result) {
|
|
57
|
+
const rows = result.rows;
|
|
58
|
+
return {
|
|
59
|
+
command: "",
|
|
60
|
+
rowCount: result.affectedRows && result.affectedRows > 0 ? result.affectedRows : rows.length,
|
|
61
|
+
oid: 0,
|
|
62
|
+
rows,
|
|
63
|
+
fields: (result.fields ?? []),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
async query(text, params) {
|
|
67
|
+
return this.enqueue(async () => this.normalize(await this.database.query(text, params)));
|
|
68
|
+
}
|
|
69
|
+
async exec(text) {
|
|
70
|
+
await this.enqueue(async () => {
|
|
71
|
+
await this.database.exec(text);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async withTransaction(fn) {
|
|
75
|
+
return this.enqueue(async () => {
|
|
76
|
+
await this.database.exec("BEGIN");
|
|
77
|
+
const client = {
|
|
78
|
+
query: async (text, params) => this.normalize(await this.database.query(text, params)),
|
|
79
|
+
exec: async (text) => {
|
|
80
|
+
await this.database.exec(text);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
try {
|
|
84
|
+
const result = await fn(client);
|
|
85
|
+
await this.database.exec("COMMIT");
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
await this.database.exec("ROLLBACK");
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
async healthcheck() {
|
|
95
|
+
await this.enqueue(async () => {
|
|
96
|
+
await this.database.query("SELECT 1");
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
async close() {
|
|
100
|
+
await this.enqueue(async () => {
|
|
101
|
+
await this.database.close();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function createPostgresDatabase(config) {
|
|
34
106
|
const pool = new Pool({
|
|
35
107
|
connectionString: config.databaseUrl,
|
|
36
108
|
max: config.databasePoolMax,
|
|
37
109
|
});
|
|
38
110
|
return new PostgresDatabase(pool);
|
|
39
111
|
}
|
|
112
|
+
async function createLocalDatabase(config) {
|
|
113
|
+
await fs.mkdir(path.dirname(config.localDatabasePath), { recursive: true });
|
|
114
|
+
const database = new PGlite(config.localDatabasePath);
|
|
115
|
+
return new LocalDatabase(database);
|
|
116
|
+
}
|
|
117
|
+
export async function createSqlDatabase(config) {
|
|
118
|
+
if (config.databaseMode === "postgres") {
|
|
119
|
+
return createPostgresDatabase(config);
|
|
120
|
+
}
|
|
121
|
+
return createLocalDatabase(config);
|
|
122
|
+
}
|
|
@@ -7,11 +7,20 @@ class InMemoryDatabase {
|
|
|
7
7
|
async query(text, params) {
|
|
8
8
|
return this.pool.query(text, params);
|
|
9
9
|
}
|
|
10
|
+
async exec(text) {
|
|
11
|
+
await this.pool.query(text);
|
|
12
|
+
}
|
|
10
13
|
async withTransaction(fn) {
|
|
11
14
|
const client = await this.pool.connect();
|
|
12
15
|
try {
|
|
13
16
|
await client.query("BEGIN");
|
|
14
|
-
const
|
|
17
|
+
const transactionalClient = {
|
|
18
|
+
query: (text, params) => client.query(text, params),
|
|
19
|
+
exec: async (text) => {
|
|
20
|
+
await client.query(text);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
const result = await fn(transactionalClient);
|
|
15
24
|
await client.query("COMMIT");
|
|
16
25
|
return result;
|
|
17
26
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
export async function runMigrations(database, migrationsDir) {
|
|
4
|
-
await database.
|
|
4
|
+
await database.exec(`
|
|
5
5
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
6
6
|
version TEXT PRIMARY KEY,
|
|
7
7
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
@@ -20,7 +20,7 @@ export async function runMigrations(database, migrationsDir) {
|
|
|
20
20
|
}
|
|
21
21
|
const sql = await fs.readFile(path.join(migrationsDir, fileName), "utf8");
|
|
22
22
|
await database.withTransaction(async (client) => {
|
|
23
|
-
await client.
|
|
23
|
+
await client.exec(sql);
|
|
24
24
|
await client.query("INSERT INTO schema_migrations(version) VALUES ($1)", [version]);
|
|
25
25
|
});
|
|
26
26
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simonsbs/keylore",
|
|
3
|
-
"version": "1.0.0-
|
|
3
|
+
"version": "1.0.0-rc5",
|
|
4
4
|
"description": "MCP credential broker and searchable credential catalogue for LLM coding tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"test": "node --test --import tsx src/test/**/*.test.ts"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
+
"@electric-sql/pglite": "^0.3.16",
|
|
66
67
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
67
68
|
"pg": "^8.20.0",
|
|
68
69
|
"pg-mem": "^3.0.14",
|