vault-cortex 0.13.1 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/init.js CHANGED
@@ -86,28 +86,52 @@ const parseHttpUrl = (value) => {
86
86
  return null;
87
87
  }
88
88
  };
89
+ /** Validates a PUBLIC_URL value: must be http(s), no credentials, no /mcp suffix. */
90
+ export const validatePublicUrl = (input) => {
91
+ const trimmed = input.trim();
92
+ const url = parseHttpUrl(trimmed);
93
+ if (!url) {
94
+ return {
95
+ kind: "error",
96
+ message: "PUBLIC_URL must be a full http:// or https:// URL (e.g. https://vault.example.com).",
97
+ };
98
+ }
99
+ if (url.username || url.password) {
100
+ return {
101
+ kind: "error",
102
+ message: "PUBLIC_URL must not contain credentials (user:password@).",
103
+ };
104
+ }
105
+ // Raw-string check: url.search/url.hash return "" for bare delimiters
106
+ // (WHATWG spec treats empty-string and null query/fragment identically),
107
+ // so a parsed-property check misses "https://host/?" and "https://host/#".
108
+ if (trimmed.includes("?") || trimmed.includes("#")) {
109
+ return {
110
+ kind: "error",
111
+ message: "PUBLIC_URL must be a bare origin or path — no query string (?...) or fragment (#...).",
112
+ };
113
+ }
114
+ if (TRAILING_MCP_PATH.test(url.pathname)) {
115
+ return {
116
+ kind: "error",
117
+ message: "Leave /mcp off PUBLIC_URL — it's the base URL and the server adds /mcp itself (e.g. https://vault.example.com).",
118
+ };
119
+ }
120
+ // Trim trailing slashes so the connect URL is `${base}/mcp`, never
121
+ // `${base}//mcp` — URL.href/.origin don't round-trip reverse-proxy subpaths.
122
+ return { kind: "ok", url: trimmed.replace(/\/+$/, "") };
123
+ };
89
124
  /** Re-prompts until the answer is a valid base http(s) URL (no /mcp path). */
90
125
  const askPublicUrl = async (prompts) => {
91
126
  const answer = await prompts.text("Public base URL clients will use to reach this server (no /mcp — it's added for you):", {
92
127
  placeholder: "https://vault.example.com or http://203.0.113.10:8000",
93
128
  });
94
- const trimmed = answer.trim();
95
- const url = parseHttpUrl(trimmed);
96
- if (url === null) {
97
- prompts.error("PUBLIC_URL must be a full http:// or https:// URL (e.g. https://vault.example.com).");
98
- return askPublicUrl(prompts);
99
- }
100
- // Reject a re-included endpoint path instead of stripping it silently —
101
- // PUBLIC_URL is the base origin and the server adds /mcp itself.
102
- if (TRAILING_MCP_PATH.test(url.pathname)) {
103
- prompts.error("Leave /mcp off PUBLIC_URL — it's the base URL and the server adds /mcp itself (e.g. https://vault.example.com).");
129
+ const result = validatePublicUrl(answer);
130
+ if (result.kind === "error") {
131
+ prompts.error(result.message);
104
132
  return askPublicUrl(prompts);
105
133
  }
106
- // Store the input as typed, trimming only a trailing slash so the connect
107
- // URL is `${base}/mcp`, never `${base}//mcp`. URL's own normalization is
108
- // unusable here: `.href` adds a trailing slash and `.origin` drops the path,
109
- // so neither round-trips a reverse-proxy subpath like https://host/api.
110
- return trimmed.replace(/\/+$/, "");
134
+ return result.url;
111
135
  };
112
136
  /** Re-prompts until non-empty. */
113
137
  const askVaultName = async (prompts) => {
@@ -1,4 +1,6 @@
1
1
  import { DEFAULT_PORT } from "./scaffold.js";
2
+ /** Matches Moment.js [...] literal escape groups, splitting format spans from literal content. */
3
+ const MOMENT_BRACKET_ESCAPE = /\[([^\]]*)\]/g;
2
4
  // The curated prompt set — settings users most often want without reading
3
5
  // .env comments. Everything else stays documented-only in the generated
4
6
  // optional block, deliberately: every extra prompt costs init flow length.
@@ -23,6 +25,13 @@ const OPTIONAL_SETTINGS = [
23
25
  label: "Daily notes folder",
24
26
  question: "Vault folder for daily notes:",
25
27
  placeholder: "blank = use your vault's daily notes settings",
28
+ validate: (value) => {
29
+ if (value.includes(".."))
30
+ return "Path traversal (..) is not allowed in folder names.";
31
+ if (value.startsWith("/"))
32
+ return "Absolute paths are not allowed — use a vault-relative folder name.";
33
+ return undefined;
34
+ },
26
35
  },
27
36
  {
28
37
  kind: "optionalText",
@@ -30,6 +39,21 @@ const OPTIONAL_SETTINGS = [
30
39
  label: "Daily notes format",
31
40
  question: "Filename date format for daily notes (e.g. YYYY-MM-DD):",
32
41
  placeholder: "blank = use your vault's daily notes settings",
42
+ validate: (value) => {
43
+ if (value.includes(".."))
44
+ return "Date format must not contain path traversal (..).";
45
+ if (value.startsWith("/"))
46
+ return "Date format must not start with a path separator.";
47
+ if (value.endsWith("/"))
48
+ return "Date format must not end with a path separator.";
49
+ // Moment format tokens are all letters — digits outside of [...]
50
+ // bracket escapes are almost always a mistake.
51
+ const formatSegments = value.split(MOMENT_BRACKET_ESCAPE);
52
+ const hasDigitsInFormat = formatSegments.some((segment, index) => index % 2 === 0 && /\d/.test(segment));
53
+ if (hasDigitsInFormat)
54
+ return "Date format should use Moment tokens (YYYY, MM, DD), not digits — wrap literal text in [...] brackets.";
55
+ return undefined;
56
+ },
33
57
  },
34
58
  {
35
59
  kind: "toggle",
@@ -193,8 +217,17 @@ const askFolder = async (params, prompts) => {
193
217
  defaultValue: currentValue ?? defaultValue,
194
218
  placeholder: defaultValue,
195
219
  })).trim();
196
- if (answer !== "")
220
+ if (answer !== "") {
221
+ if (answer.includes("..")) {
222
+ prompts.error("Path traversal (..) is not allowed in folder names.");
223
+ return askFolder(params, prompts);
224
+ }
225
+ if (answer.startsWith("/")) {
226
+ prompts.error("Absolute paths are not allowed — use a vault-relative folder name.");
227
+ return askFolder(params, prompts);
228
+ }
197
229
  return answer;
230
+ }
198
231
  prompts.error("The folder name can't be empty.");
199
232
  return askFolder(params, prompts);
200
233
  };
@@ -251,12 +284,21 @@ const askSettingValue = async (params, prompts) => {
251
284
  currentValue,
252
285
  defaultValue: setting.defaultValue,
253
286
  }, prompts);
254
- case "optionalText":
255
- return askOptionalText({
287
+ case "optionalText": {
288
+ const value = await askOptionalText({
256
289
  question: setting.question,
257
290
  placeholder: setting.placeholder,
258
291
  currentValue,
259
292
  }, prompts);
293
+ if (value && setting.validate) {
294
+ const error = setting.validate(value);
295
+ if (error) {
296
+ prompts.error(error);
297
+ return askSettingValue(params, prompts);
298
+ }
299
+ }
300
+ return value;
301
+ }
260
302
  case "choice":
261
303
  return prompts.select(setting.question, setting.choices, currentValue ?? setting.defaultValue);
262
304
  }
package/dist/vault.js CHANGED
@@ -21,6 +21,11 @@ export const validateVaultPath = (input) => {
21
21
  const trimmed = input.trim();
22
22
  if (trimmed === "")
23
23
  return { kind: "error", message: "Vault path is required." };
24
+ if (/[*?[]/.test(trimmed))
25
+ return {
26
+ kind: "error",
27
+ message: "Vault path must not contain glob characters (*, ?, [).",
28
+ };
24
29
  const absolutePath = resolve(expandTilde(trimmed));
25
30
  if (!existsSync(absolutePath)) {
26
31
  return { kind: "error", message: `Path does not exist: ${absolutePath}` };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vault-cortex",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
4
4
  "description": "Set up a Vault Cortex MCP server for your Obsidian vault in one command: npx vault-cortex@latest init",
5
5
  "license": "MIT",
6
6
  "type": "module",