@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.
@@ -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 before attempting access.",
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 === parsed.id)) {
61
- throw new Error(`Credential ${parsed.id} already exists.`);
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(parsed);
88
+ catalog.credentials.push(normalized);
64
89
  await this.writeCatalog(catalog);
65
- return parsed;
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
- ...catalog.credentials[index],
107
+ ...current,
82
108
  ...parsedPatch,
83
109
  id,
84
110
  });
85
- catalog.credentials[index] = merged;
111
+ const normalized = {
112
+ ...merged,
113
+ ...normalizedUpdateContexts(current, parsedPatch),
114
+ };
115
+ catalog.credentials[index] = normalized;
86
116
  await this.writeCatalog(catalog);
87
- return merged;
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: row.selection_notes,
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, $15, $16
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
- parsed.selectionNotes,
128
+ llmContext,
129
+ userContext,
130
+ llmContext,
107
131
  JSON.stringify(parsed.binding),
108
132
  parsed.tags,
109
133
  parsed.status,
110
134
  ]);
111
- return parsed;
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
- binding = $13::jsonb,
137
- tags = $14,
138
- status = $15,
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
- merged.selectionNotes,
184
+ llmContext,
185
+ userContext,
186
+ llmContext,
153
187
  JSON.stringify(merged.binding),
154
188
  merged.tags,
155
189
  merged.status,
156
190
  ]);
157
- return merged;
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
- credential.selectionNotes,
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simonsbs/keylore",
3
- "version": "1.0.0-rc5",
3
+ "version": "1.0.0",
4
4
  "description": "MCP credential broker and searchable credential catalogue for LLM coding tools.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",