@simonsbs/keylore 1.0.0-rc5 → 1.0.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/README.md +24 -13
- package/data/catalog.json +4 -0
- package/dist/config.js +1 -1
- package/dist/domain/types.js +60 -46
- package/dist/http/admin-ui.js +1068 -371
- package/dist/http/server.js +173 -0
- package/dist/mcp/create-server.js +4 -4
- package/dist/repositories/credential-repository.js +37 -7
- package/dist/repositories/pg-credential-repository.js +50 -11
- package/dist/services/backup-service.js +10 -4
- package/dist/services/core-mode-service.js +17 -1
- package/migrations/009_v1_context_split.sql +30 -0
- package/package.json +1 -1
package/dist/http/server.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
1
6
|
import { randomUUID } from "node:crypto";
|
|
2
7
|
import http from "node:http";
|
|
3
8
|
import { URL } from "node:url";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
4
10
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
5
11
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
6
12
|
import * as z from "zod/v4";
|
|
@@ -8,6 +14,13 @@ import { accessRequestInputSchema, backupInspectOutputSchema, breakGlassRequestI
|
|
|
8
14
|
import { renderAdminPage } from "./admin-ui.js";
|
|
9
15
|
import { createKeyLoreMcpServer } from "../mcp/create-server.js";
|
|
10
16
|
import { authContextFromToken } from "../services/auth-context.js";
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
const applyToolConfigInputSchema = z.object({
|
|
19
|
+
tool: z.enum(["codex", "gemini", "claude"]),
|
|
20
|
+
});
|
|
21
|
+
const replaceLocalSecretInputSchema = z.object({
|
|
22
|
+
secretValue: z.string().min(1),
|
|
23
|
+
});
|
|
11
24
|
function respondJson(res, statusCode, payload) {
|
|
12
25
|
res.writeHead(statusCode, { "content-type": "application/json; charset=utf-8" });
|
|
13
26
|
res.end(`${JSON.stringify(payload, null, 2)}\n`);
|
|
@@ -24,6 +37,124 @@ function respondRedirect(res, location) {
|
|
|
24
37
|
res.writeHead(302, { location });
|
|
25
38
|
res.end();
|
|
26
39
|
}
|
|
40
|
+
function userHomeDirectory() {
|
|
41
|
+
return process.env.HOME || os.homedir();
|
|
42
|
+
}
|
|
43
|
+
function resolveLocalStdioEntryPath() {
|
|
44
|
+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
45
|
+
const packageRoot = path.resolve(moduleDir, "..", "..");
|
|
46
|
+
const builtEntry = path.join(packageRoot, "dist", "index.js");
|
|
47
|
+
return builtEntry;
|
|
48
|
+
}
|
|
49
|
+
async function ensureParentDirectory(filePath) {
|
|
50
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
async function readTextFileIfExists(filePath) {
|
|
53
|
+
try {
|
|
54
|
+
return await readFile(filePath, "utf8");
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error.code === "ENOENT") {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function codexManagedBlock(stdioEntryPath) {
|
|
64
|
+
const escapedPath = JSON.stringify(stdioEntryPath.replace(/\\/g, "\\\\"));
|
|
65
|
+
return [
|
|
66
|
+
"# >>> keylore managed start",
|
|
67
|
+
"[mcp_servers.keylore_stdio]",
|
|
68
|
+
'command = "node"',
|
|
69
|
+
`args = [${escapedPath}, "--transport", "stdio"]`,
|
|
70
|
+
"# <<< keylore managed end",
|
|
71
|
+
].join("\n");
|
|
72
|
+
}
|
|
73
|
+
function replaceManagedTomlBlock(content, tableName, managedBlock) {
|
|
74
|
+
const normalized = content.replace(/\r\n/g, "\n");
|
|
75
|
+
const managedStart = "# >>> keylore managed start";
|
|
76
|
+
const managedEnd = "# <<< keylore managed end";
|
|
77
|
+
const managedPattern = new RegExp(`${managedStart.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${managedEnd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n?`, "m");
|
|
78
|
+
if (managedPattern.test(normalized)) {
|
|
79
|
+
const next = normalized.replace(managedPattern, `${managedBlock}\n`);
|
|
80
|
+
return { next, changed: next !== normalized };
|
|
81
|
+
}
|
|
82
|
+
const lines = normalized.split("\n");
|
|
83
|
+
const tableHeader = `[${tableName}]`;
|
|
84
|
+
const startIndex = lines.findIndex((line) => line.trim() === tableHeader);
|
|
85
|
+
if (startIndex >= 0) {
|
|
86
|
+
let endIndex = lines.length;
|
|
87
|
+
for (let index = startIndex + 1; index < lines.length; index += 1) {
|
|
88
|
+
const candidate = lines[index];
|
|
89
|
+
if (candidate && candidate.trim().startsWith("[") && candidate.trim() !== tableHeader) {
|
|
90
|
+
endIndex = index;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const before = lines.slice(0, startIndex).join("\n");
|
|
95
|
+
const after = lines.slice(endIndex).join("\n");
|
|
96
|
+
const next = `${before}${before ? "\n" : ""}${managedBlock}${after ? `\n${after}` : ""}`.replace(/\n{3,}/g, "\n\n");
|
|
97
|
+
return { next, changed: next !== normalized };
|
|
98
|
+
}
|
|
99
|
+
const next = `${normalized.trimEnd()}${normalized.trimEnd() ? "\n\n" : ""}${managedBlock}\n`;
|
|
100
|
+
return { next, changed: next !== normalized };
|
|
101
|
+
}
|
|
102
|
+
async function applyCodexLocalConfig(stdioEntryPath) {
|
|
103
|
+
const filePath = path.join(userHomeDirectory(), ".codex", "config.toml");
|
|
104
|
+
const existing = (await readTextFileIfExists(filePath)) ?? "";
|
|
105
|
+
const managedBlock = codexManagedBlock(stdioEntryPath);
|
|
106
|
+
const { next, changed } = replaceManagedTomlBlock(existing, "mcp_servers.keylore_stdio", managedBlock);
|
|
107
|
+
await ensureParentDirectory(filePath);
|
|
108
|
+
if (changed || existing.length === 0) {
|
|
109
|
+
await writeFile(filePath, next, "utf8");
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
path: filePath,
|
|
113
|
+
action: existing.length === 0 ? "created" : changed ? "updated" : "unchanged",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
async function applyGeminiLocalConfig(stdioEntryPath) {
|
|
117
|
+
const filePath = path.join(userHomeDirectory(), ".gemini", "settings.json");
|
|
118
|
+
const existing = (await readTextFileIfExists(filePath)) ?? "";
|
|
119
|
+
let parsed = {};
|
|
120
|
+
if (existing.trim().length > 0) {
|
|
121
|
+
parsed = JSON.parse(existing);
|
|
122
|
+
}
|
|
123
|
+
const currentServers = parsed.mcpServers && typeof parsed.mcpServers === "object" && !Array.isArray(parsed.mcpServers)
|
|
124
|
+
? { ...parsed.mcpServers }
|
|
125
|
+
: {};
|
|
126
|
+
currentServers.keylore_stdio = {
|
|
127
|
+
command: "node",
|
|
128
|
+
args: [stdioEntryPath, "--transport", "stdio"],
|
|
129
|
+
};
|
|
130
|
+
parsed.mcpServers = currentServers;
|
|
131
|
+
const next = `${JSON.stringify(parsed, null, 2)}\n`;
|
|
132
|
+
await ensureParentDirectory(filePath);
|
|
133
|
+
if (next !== existing) {
|
|
134
|
+
await writeFile(filePath, next, "utf8");
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
path: filePath,
|
|
138
|
+
action: existing.length === 0 ? "created" : next !== existing ? "updated" : "unchanged",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
async function applyClaudeLocalConfig(stdioEntryPath) {
|
|
142
|
+
const commandJson = JSON.stringify({
|
|
143
|
+
command: "node",
|
|
144
|
+
args: [stdioEntryPath, "--transport", "stdio"],
|
|
145
|
+
});
|
|
146
|
+
try {
|
|
147
|
+
await execFileAsync("claude", ["mcp", "remove", "-s", "user", "keylore_stdio"]);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// Ignore missing existing config entries.
|
|
151
|
+
}
|
|
152
|
+
await execFileAsync("claude", ["mcp", "add-json", "-s", "user", "keylore_stdio", commandJson]);
|
|
153
|
+
return {
|
|
154
|
+
path: path.join(userHomeDirectory(), ".claude", "settings.json"),
|
|
155
|
+
action: "updated",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
27
158
|
async function readBody(req, maxBytes) {
|
|
28
159
|
const contentLengthHeader = req.headers["content-length"];
|
|
29
160
|
const contentLength = typeof contentLengthHeader === "string" ? Number.parseInt(contentLengthHeader, 10) : undefined;
|
|
@@ -480,6 +611,36 @@ async function handleApiRequest(app, req, res, url) {
|
|
|
480
611
|
});
|
|
481
612
|
return;
|
|
482
613
|
}
|
|
614
|
+
if (url.pathname === "/v1/core/tooling/apply" && req.method === "POST") {
|
|
615
|
+
const context = await authenticateRequest(app, req, res, ["catalog:write"], "api", `${app.config.publicBaseUrl}/v1`);
|
|
616
|
+
if (!context) {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
app.auth.requireRoles(context, ["admin", "operator"]);
|
|
620
|
+
if (!isLoopbackRequest(req) || app.config.environment === "production") {
|
|
621
|
+
respondJson(res, 403, { error: "Local tool setup is only available from loopback development instances." });
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
const body = applyToolConfigInputSchema.parse(await readJsonBody(req, app.config.maxRequestBytes));
|
|
625
|
+
let result;
|
|
626
|
+
if (body.tool === "codex") {
|
|
627
|
+
result = await applyCodexLocalConfig(resolveLocalStdioEntryPath());
|
|
628
|
+
}
|
|
629
|
+
else if (body.tool === "gemini") {
|
|
630
|
+
result = await applyGeminiLocalConfig(resolveLocalStdioEntryPath());
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
result = await applyClaudeLocalConfig(resolveLocalStdioEntryPath());
|
|
634
|
+
}
|
|
635
|
+
respondJson(res, 200, {
|
|
636
|
+
ok: true,
|
|
637
|
+
tool: body.tool,
|
|
638
|
+
path: result.path,
|
|
639
|
+
action: result.action,
|
|
640
|
+
connection: "local_stdio",
|
|
641
|
+
});
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
483
644
|
if (url.pathname === "/v1/catalog/credentials" && req.method === "GET") {
|
|
484
645
|
const context = await authenticateRequest(app, req, res, ["catalog:read"], "api", `${app.config.publicBaseUrl}/v1`);
|
|
485
646
|
if (!context) {
|
|
@@ -539,6 +700,18 @@ async function handleApiRequest(app, req, res, url) {
|
|
|
539
700
|
respondJson(res, 200, { credential });
|
|
540
701
|
return;
|
|
541
702
|
}
|
|
703
|
+
if (coreCredentialContextId && url.pathname.endsWith("/local-secret") && req.method === "POST") {
|
|
704
|
+
const context = await authenticateRequest(app, req, res, ["catalog:write"], "api", `${app.config.publicBaseUrl}/v1`);
|
|
705
|
+
if (!context) {
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
app.auth.requireRoles(context, ["admin", "operator"]);
|
|
709
|
+
const credentialId = coreCredentialContextId.replace(/\/local-secret$/, "");
|
|
710
|
+
const body = replaceLocalSecretInputSchema.parse(await readJsonBody(req, app.config.maxRequestBytes));
|
|
711
|
+
const credential = await app.coreMode.replaceLocalSecret(context, credentialId, body.secretValue);
|
|
712
|
+
respondJson(res, 200, { credential, updatedSecret: true });
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
542
715
|
if (coreCredentialContextId && !url.pathname.endsWith("/context") && req.method === "DELETE") {
|
|
543
716
|
const context = await authenticateRequest(app, req, res, ["catalog:write"], "api", `${app.config.publicBaseUrl}/v1`);
|
|
544
717
|
if (!context) {
|
|
@@ -28,7 +28,7 @@ export function createKeyLoreMcpServer(app) {
|
|
|
28
28
|
version: app.config.version,
|
|
29
29
|
});
|
|
30
30
|
server.registerTool("catalog_search", {
|
|
31
|
-
description: "Search credential metadata without exposing secret values. Use this
|
|
31
|
+
description: "Search credential metadata without exposing secret values. Use this first to find the best credential by purpose, service, domain, permitted operation, tags, and LLM/user context. Do not assume the requested token name matches the credential ID.",
|
|
32
32
|
inputSchema: {
|
|
33
33
|
query: z.string().optional(),
|
|
34
34
|
service: z.string().optional(),
|
|
@@ -54,7 +54,7 @@ export function createKeyLoreMcpServer(app) {
|
|
|
54
54
|
};
|
|
55
55
|
});
|
|
56
56
|
server.registerTool("catalog_get", {
|
|
57
|
-
description: "Return one credential metadata record by identifier, still without secrets.",
|
|
57
|
+
description: "Return one credential metadata record by identifier, still without secrets. Use this after search when you need to inspect one candidate more closely before choosing it for access.",
|
|
58
58
|
inputSchema: {
|
|
59
59
|
credentialId: z.string().min(1),
|
|
60
60
|
},
|
|
@@ -94,7 +94,7 @@ export function createKeyLoreMcpServer(app) {
|
|
|
94
94
|
};
|
|
95
95
|
});
|
|
96
96
|
server.registerTool("access_request", {
|
|
97
|
-
description: "Evaluate policy and, if allowed, execute a constrained authenticated proxy request without returning secret material.",
|
|
97
|
+
description: "Evaluate policy and, if allowed, execute a constrained authenticated proxy request without returning secret material. Use the credential selected from metadata context, domains, and allowed operations, not just because its name looks similar to the request.",
|
|
98
98
|
inputSchema: {
|
|
99
99
|
credentialId: z.string().min(1),
|
|
100
100
|
operation: operationSchema,
|
|
@@ -115,7 +115,7 @@ export function createKeyLoreMcpServer(app) {
|
|
|
115
115
|
};
|
|
116
116
|
});
|
|
117
117
|
server.registerTool("policy_simulate", {
|
|
118
|
-
description: "Evaluate policy for a proposed access request without executing the outbound call or creating approval side effects.",
|
|
118
|
+
description: "Evaluate policy for a proposed access request without executing the outbound call or creating approval side effects. Use this to compare likely credentials and confirm the context-matched choice before making a live request.",
|
|
119
119
|
inputSchema: {
|
|
120
120
|
credentialId: z.string().min(1),
|
|
121
121
|
operation: operationSchema,
|
|
@@ -4,6 +4,23 @@ import { readTextFile, writeTextFile } from "./json-file.js";
|
|
|
4
4
|
function normalizeText(value) {
|
|
5
5
|
return value.trim().toLowerCase();
|
|
6
6
|
}
|
|
7
|
+
function normalizedLlmContext(credential) {
|
|
8
|
+
return credential.llmContext?.trim() || credential.selectionNotes;
|
|
9
|
+
}
|
|
10
|
+
function normalizedUserContext(credential) {
|
|
11
|
+
return credential.userContext?.trim() || normalizedLlmContext(credential);
|
|
12
|
+
}
|
|
13
|
+
function normalizedUpdateContexts(current, patch) {
|
|
14
|
+
const llmContext = patch.llmContext?.trim() ??
|
|
15
|
+
patch.selectionNotes?.trim() ??
|
|
16
|
+
current.llmContext?.trim() ??
|
|
17
|
+
current.selectionNotes;
|
|
18
|
+
return {
|
|
19
|
+
selectionNotes: llmContext,
|
|
20
|
+
llmContext,
|
|
21
|
+
userContext: patch.userContext?.trim() ?? current.userContext?.trim() ?? llmContext,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
7
24
|
function matchesQuery(credential, query) {
|
|
8
25
|
if (!query) {
|
|
9
26
|
return true;
|
|
@@ -13,6 +30,8 @@ function matchesQuery(credential, query) {
|
|
|
13
30
|
credential.displayName,
|
|
14
31
|
credential.service,
|
|
15
32
|
credential.owner,
|
|
33
|
+
credential.userContext ?? "",
|
|
34
|
+
credential.llmContext ?? "",
|
|
16
35
|
credential.selectionNotes,
|
|
17
36
|
...credential.tags,
|
|
18
37
|
]
|
|
@@ -56,13 +75,19 @@ export class JsonCredentialRepository {
|
|
|
56
75
|
}
|
|
57
76
|
async create(record) {
|
|
58
77
|
const parsed = createCredentialInputSchema.parse(record);
|
|
78
|
+
const normalized = {
|
|
79
|
+
...parsed,
|
|
80
|
+
userContext: normalizedUserContext(parsed),
|
|
81
|
+
llmContext: normalizedLlmContext(parsed),
|
|
82
|
+
selectionNotes: normalizedLlmContext(parsed),
|
|
83
|
+
};
|
|
59
84
|
const catalog = await this.readCatalog();
|
|
60
|
-
if (catalog.credentials.some((credential) => credential.id ===
|
|
61
|
-
throw new Error(`Credential ${
|
|
85
|
+
if (catalog.credentials.some((credential) => credential.id === normalized.id)) {
|
|
86
|
+
throw new Error(`Credential ${normalized.id} already exists.`);
|
|
62
87
|
}
|
|
63
|
-
catalog.credentials.push(
|
|
88
|
+
catalog.credentials.push(normalized);
|
|
64
89
|
await this.writeCatalog(catalog);
|
|
65
|
-
return
|
|
90
|
+
return normalized;
|
|
66
91
|
}
|
|
67
92
|
async createWithDefaults(record) {
|
|
68
93
|
return this.create({
|
|
@@ -77,14 +102,19 @@ export class JsonCredentialRepository {
|
|
|
77
102
|
if (index === -1) {
|
|
78
103
|
throw new Error(`Credential ${id} was not found.`);
|
|
79
104
|
}
|
|
105
|
+
const current = catalog.credentials[index];
|
|
80
106
|
const merged = createCredentialInputSchema.parse({
|
|
81
|
-
...
|
|
107
|
+
...current,
|
|
82
108
|
...parsedPatch,
|
|
83
109
|
id,
|
|
84
110
|
});
|
|
85
|
-
|
|
111
|
+
const normalized = {
|
|
112
|
+
...merged,
|
|
113
|
+
...normalizedUpdateContexts(current, parsedPatch),
|
|
114
|
+
};
|
|
115
|
+
catalog.credentials[index] = normalized;
|
|
86
116
|
await this.writeCatalog(catalog);
|
|
87
|
-
return
|
|
117
|
+
return normalized;
|
|
88
118
|
}
|
|
89
119
|
async delete(id) {
|
|
90
120
|
const catalog = await this.readCatalog();
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createCredentialInputSchema, credentialRecordSchema, updateCredentialInputSchema, } from "../domain/types.js";
|
|
2
2
|
function mapRow(row) {
|
|
3
|
+
const llmContext = row.llm_context?.trim() || row.selection_notes;
|
|
4
|
+
const userContext = row.user_context?.trim() || llmContext;
|
|
3
5
|
return credentialRecordSchema.parse({
|
|
4
6
|
id: row.id,
|
|
5
7
|
tenantId: row.tenant_id,
|
|
@@ -15,7 +17,9 @@ function mapRow(row) {
|
|
|
15
17
|
lastValidatedAt: row.last_validated_at instanceof Date
|
|
16
18
|
? row.last_validated_at.toISOString()
|
|
17
19
|
: row.last_validated_at,
|
|
18
|
-
selectionNotes:
|
|
20
|
+
selectionNotes: llmContext,
|
|
21
|
+
userContext,
|
|
22
|
+
llmContext,
|
|
19
23
|
binding: row.binding,
|
|
20
24
|
tags: row.tags,
|
|
21
25
|
status: row.status,
|
|
@@ -31,7 +35,7 @@ function makeSearchClauses(input) {
|
|
|
31
35
|
if (input.query) {
|
|
32
36
|
params.push(input.query);
|
|
33
37
|
const placeholder = `$${params.length}`;
|
|
34
|
-
conditions.push(`(id ILIKE '%' || ${placeholder} || '%' OR display_name ILIKE '%' || ${placeholder} || '%' OR service ILIKE '%' || ${placeholder} || '%' OR owner ILIKE '%' || ${placeholder} || '%' OR selection_notes ILIKE '%' || ${placeholder} || '%')`);
|
|
38
|
+
conditions.push(`(id ILIKE '%' || ${placeholder} || '%' OR display_name ILIKE '%' || ${placeholder} || '%' OR service ILIKE '%' || ${placeholder} || '%' OR owner ILIKE '%' || ${placeholder} || '%' OR selection_notes ILIKE '%' || ${placeholder} || '%' OR COALESCE(user_context, '') ILIKE '%' || ${placeholder} || '%' OR COALESCE(llm_context, '') ILIKE '%' || ${placeholder} || '%')`);
|
|
35
39
|
}
|
|
36
40
|
if (input.service) {
|
|
37
41
|
push("service = $?", input.service);
|
|
@@ -54,6 +58,23 @@ function makeSearchClauses(input) {
|
|
|
54
58
|
const clause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
55
59
|
return { clause, params };
|
|
56
60
|
}
|
|
61
|
+
function normalizedCreateContexts(record) {
|
|
62
|
+
const llmContext = record.llmContext?.trim() || record.selectionNotes;
|
|
63
|
+
return {
|
|
64
|
+
llmContext,
|
|
65
|
+
userContext: record.userContext?.trim() || llmContext,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function normalizedUpdateContexts(current, patch) {
|
|
69
|
+
const llmContext = patch.llmContext?.trim() ??
|
|
70
|
+
patch.selectionNotes?.trim() ??
|
|
71
|
+
current.llmContext?.trim() ??
|
|
72
|
+
current.selectionNotes;
|
|
73
|
+
return {
|
|
74
|
+
llmContext,
|
|
75
|
+
userContext: patch.userContext?.trim() ?? current.userContext?.trim() ?? llmContext,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
57
78
|
export class PgCredentialRepository {
|
|
58
79
|
database;
|
|
59
80
|
constructor(database) {
|
|
@@ -82,14 +103,15 @@ export class PgCredentialRepository {
|
|
|
82
103
|
}
|
|
83
104
|
async create(record) {
|
|
84
105
|
const parsed = createCredentialInputSchema.parse(record);
|
|
106
|
+
const { llmContext, userContext } = normalizedCreateContexts(parsed);
|
|
85
107
|
await this.database.query(`INSERT INTO credentials (
|
|
86
108
|
id, tenant_id, display_name, service, owner, scope_tier, sensitivity,
|
|
87
109
|
allowed_domains, permitted_operations, expires_at, rotation_policy,
|
|
88
|
-
last_validated_at, selection_notes, binding, tags, status
|
|
110
|
+
last_validated_at, selection_notes, user_context, llm_context, binding, tags, status
|
|
89
111
|
) VALUES (
|
|
90
112
|
$1, $2, $3, $4, $5, $6, $7,
|
|
91
113
|
$8, $9, $10, $11,
|
|
92
|
-
$12, $13, $14::jsonb, $
|
|
114
|
+
$12, $13, $14, $15, $16::jsonb, $17, $18
|
|
93
115
|
)`, [
|
|
94
116
|
parsed.id,
|
|
95
117
|
parsed.tenantId,
|
|
@@ -103,12 +125,19 @@ export class PgCredentialRepository {
|
|
|
103
125
|
parsed.expiresAt,
|
|
104
126
|
parsed.rotationPolicy,
|
|
105
127
|
parsed.lastValidatedAt,
|
|
106
|
-
|
|
128
|
+
llmContext,
|
|
129
|
+
userContext,
|
|
130
|
+
llmContext,
|
|
107
131
|
JSON.stringify(parsed.binding),
|
|
108
132
|
parsed.tags,
|
|
109
133
|
parsed.status,
|
|
110
134
|
]);
|
|
111
|
-
return
|
|
135
|
+
return credentialRecordSchema.parse({
|
|
136
|
+
...parsed,
|
|
137
|
+
selectionNotes: llmContext,
|
|
138
|
+
userContext,
|
|
139
|
+
llmContext,
|
|
140
|
+
});
|
|
112
141
|
}
|
|
113
142
|
async update(id, patch) {
|
|
114
143
|
const parsedPatch = updateCredentialInputSchema.parse(patch);
|
|
@@ -121,6 +150,7 @@ export class PgCredentialRepository {
|
|
|
121
150
|
...parsedPatch,
|
|
122
151
|
id,
|
|
123
152
|
});
|
|
153
|
+
const { llmContext, userContext } = normalizedUpdateContexts(current, parsedPatch);
|
|
124
154
|
await this.database.query(`UPDATE credentials SET
|
|
125
155
|
display_name = $2,
|
|
126
156
|
service = $3,
|
|
@@ -133,9 +163,11 @@ export class PgCredentialRepository {
|
|
|
133
163
|
rotation_policy = $10,
|
|
134
164
|
last_validated_at = $11,
|
|
135
165
|
selection_notes = $12,
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
166
|
+
user_context = $13,
|
|
167
|
+
llm_context = $14,
|
|
168
|
+
binding = $15::jsonb,
|
|
169
|
+
tags = $16,
|
|
170
|
+
status = $17,
|
|
139
171
|
updated_at = NOW()
|
|
140
172
|
WHERE id = $1`, [
|
|
141
173
|
merged.id,
|
|
@@ -149,12 +181,19 @@ export class PgCredentialRepository {
|
|
|
149
181
|
merged.expiresAt,
|
|
150
182
|
merged.rotationPolicy,
|
|
151
183
|
merged.lastValidatedAt,
|
|
152
|
-
|
|
184
|
+
llmContext,
|
|
185
|
+
userContext,
|
|
186
|
+
llmContext,
|
|
153
187
|
JSON.stringify(merged.binding),
|
|
154
188
|
merged.tags,
|
|
155
189
|
merged.status,
|
|
156
190
|
]);
|
|
157
|
-
return
|
|
191
|
+
return credentialRecordSchema.parse({
|
|
192
|
+
...merged,
|
|
193
|
+
selectionNotes: llmContext,
|
|
194
|
+
userContext,
|
|
195
|
+
llmContext,
|
|
196
|
+
});
|
|
158
197
|
}
|
|
159
198
|
async delete(id) {
|
|
160
199
|
const result = await this.database.query("DELETE FROM credentials WHERE id = $1", [id]);
|
|
@@ -154,7 +154,9 @@ export class BackupService {
|
|
|
154
154
|
expiresAt: toIso(row.expires_at),
|
|
155
155
|
rotationPolicy: row.rotation_policy,
|
|
156
156
|
lastValidatedAt: toIso(row.last_validated_at),
|
|
157
|
-
selectionNotes: row.selection_notes,
|
|
157
|
+
selectionNotes: row.llm_context?.trim() || row.selection_notes,
|
|
158
|
+
userContext: row.user_context?.trim() || row.llm_context?.trim() || row.selection_notes,
|
|
159
|
+
llmContext: row.llm_context?.trim() || row.selection_notes,
|
|
158
160
|
binding: row.binding,
|
|
159
161
|
tags: row.tags,
|
|
160
162
|
status: row.status,
|
|
@@ -362,14 +364,16 @@ export class BackupService {
|
|
|
362
364
|
]);
|
|
363
365
|
}
|
|
364
366
|
for (const credential of backup.credentials) {
|
|
367
|
+
const llmContext = credential.llmContext?.trim() || credential.selectionNotes;
|
|
368
|
+
const userContext = credential.userContext?.trim() || llmContext;
|
|
365
369
|
await client.query(`INSERT INTO credentials (
|
|
366
370
|
id, tenant_id, display_name, service, owner, scope_tier, sensitivity,
|
|
367
371
|
allowed_domains, permitted_operations, expires_at, rotation_policy,
|
|
368
|
-
last_validated_at, selection_notes, binding, tags, status
|
|
372
|
+
last_validated_at, selection_notes, user_context, llm_context, binding, tags, status
|
|
369
373
|
) VALUES (
|
|
370
374
|
$1, $2, $3, $4, $5, $6, $7,
|
|
371
375
|
$8, $9, $10, $11,
|
|
372
|
-
$12, $13, $14, $15, $16
|
|
376
|
+
$12, $13, $14, $15, $16, $17, $18
|
|
373
377
|
)`, [
|
|
374
378
|
credential.id,
|
|
375
379
|
credential.tenantId,
|
|
@@ -383,7 +387,9 @@ export class BackupService {
|
|
|
383
387
|
credential.expiresAt,
|
|
384
388
|
credential.rotationPolicy,
|
|
385
389
|
credential.lastValidatedAt,
|
|
386
|
-
|
|
390
|
+
llmContext,
|
|
391
|
+
userContext,
|
|
392
|
+
llmContext,
|
|
387
393
|
credential.binding,
|
|
388
394
|
credential.tags,
|
|
389
395
|
credential.status,
|
|
@@ -99,7 +99,12 @@ export class CoreModeService {
|
|
|
99
99
|
expiresAt: parsed.expiresAt,
|
|
100
100
|
rotationPolicy: parsed.rotationPolicy,
|
|
101
101
|
lastValidatedAt: null,
|
|
102
|
-
selectionNotes: parsed.selectionNotes,
|
|
102
|
+
selectionNotes: parsed.llmContext?.trim() || parsed.selectionNotes?.trim() || "",
|
|
103
|
+
userContext: parsed.userContext?.trim() ||
|
|
104
|
+
parsed.llmContext?.trim() ||
|
|
105
|
+
parsed.selectionNotes?.trim() ||
|
|
106
|
+
"",
|
|
107
|
+
llmContext: parsed.llmContext?.trim() || parsed.selectionNotes?.trim() || "",
|
|
103
108
|
binding,
|
|
104
109
|
tags: parsed.tags,
|
|
105
110
|
status: parsed.status,
|
|
@@ -138,6 +143,17 @@ export class CoreModeService {
|
|
|
138
143
|
await this.syncCoreAllowRule(updated);
|
|
139
144
|
return updated;
|
|
140
145
|
}
|
|
146
|
+
async replaceLocalSecret(context, credentialId, secretValue) {
|
|
147
|
+
const existing = await this.broker.getCredential(context, credentialId);
|
|
148
|
+
if (!existing) {
|
|
149
|
+
throw new Error(`Credential ${credentialId} not found.`);
|
|
150
|
+
}
|
|
151
|
+
if (existing.owner !== "local") {
|
|
152
|
+
throw new Error("Only locally stored tokens can be replaced through the admin UI.");
|
|
153
|
+
}
|
|
154
|
+
await this.localSecrets.put(`local:${existing.tenantId}:${existing.id}`, secretValue);
|
|
155
|
+
return existing;
|
|
156
|
+
}
|
|
141
157
|
async deleteCredential(context, credentialId) {
|
|
142
158
|
const existing = await this.broker.getCredential(context, credentialId);
|
|
143
159
|
if (!existing) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
ALTER TABLE credentials
|
|
2
|
+
ADD COLUMN IF NOT EXISTS user_context TEXT;
|
|
3
|
+
|
|
4
|
+
ALTER TABLE credentials
|
|
5
|
+
ADD COLUMN IF NOT EXISTS llm_context TEXT;
|
|
6
|
+
|
|
7
|
+
UPDATE credentials
|
|
8
|
+
SET
|
|
9
|
+
llm_context = CASE
|
|
10
|
+
WHEN llm_context IS NULL OR llm_context = '' THEN selection_notes
|
|
11
|
+
ELSE llm_context
|
|
12
|
+
END,
|
|
13
|
+
user_context = CASE
|
|
14
|
+
WHEN user_context IS NULL OR user_context = '' THEN
|
|
15
|
+
CASE
|
|
16
|
+
WHEN llm_context IS NULL OR llm_context = '' THEN selection_notes
|
|
17
|
+
ELSE llm_context
|
|
18
|
+
END
|
|
19
|
+
ELSE user_context
|
|
20
|
+
END
|
|
21
|
+
WHERE llm_context IS NULL
|
|
22
|
+
OR llm_context = ''
|
|
23
|
+
OR user_context IS NULL
|
|
24
|
+
OR user_context = '';
|
|
25
|
+
|
|
26
|
+
ALTER TABLE credentials
|
|
27
|
+
ALTER COLUMN llm_context SET NOT NULL;
|
|
28
|
+
|
|
29
|
+
ALTER TABLE credentials
|
|
30
|
+
ALTER COLUMN user_context SET NOT NULL;
|